Acme-Parataxis

 view release on metacpan or  search on metacpan

eg/tui_bubbletea.pl  view on Meta::CPAN

use v5.40;
use experimental qw[declared_refs];
use blib;
$|++;
use Acme::Parataxis qw[:all];
use Acme::Parataxis::Channel;
use Acme::Parataxis::Signal;

# Platform setup: POSIX termios or Win32 kernel32 via Affix FFI
BEGIN {
    if ( $^O ne 'MSWin32' ) {
        require POSIX;
        POSIX->import(qw[:termios_h]);
    }
    else {
        use Affix qw[:all];
        affix 'kernel32', 'GetConsoleMode',                [ LongLong, Pointer [Void] ], Int;
        affix 'kernel32', 'SetConsoleMode',                [ LongLong, ULong ],          Int;
        affix 'kernel32', 'GetConsoleScreenBufferInfo',    [ LongLong, Pointer [Void] ], Int;
        affix 'kernel32', 'GetNumberOfConsoleInputEvents', [ LongLong, Pointer [Void] ], Int;
        affix 'kernel32', 'ReadConsoleInputA',             [ LongLong, Pointer [Void], ULong, Pointer [Void] ], Int;
    }
}
#
# A bubbletea-inspired TUI using Acme::Parataxis fibers.
#
# Architecture (Elm-like / bubbletea-style):
#   - input fiber:   reads keyboard -> MsgKey
#   - resize fiber:  $SIG{WINCH} or Win32 polling -> MsgResize
#   - tick fiber:    await_sleep(1000) -> MsgTick
#   - main loop:     Channel.get() -> Update(model, msg) -> render via View()
#
# Windows notes:
#   - Raw mode, VT processing, and input are handled via kernel32 FFI
#   - select() only works on sockets on Windows, so await_read/await_write
#     (which use select in the thread pool) don't work on console handles.
#     The input fiber uses GetNumberOfConsoleInputEvents + ReadConsoleInputA
#     polling instead.  await_write(STDOUT) is skipped since stdout is always
#     writable for a console.
#
# Terminal raw mode
my $ORIG_TERMIOS;
my $ORIG_WIN32_IN_MODE;

sub save_termios {
    if ( $^O eq 'MSWin32' ) {
        my $handle = Acme::Parataxis::win32_get_osfhandle( fileno(STDIN) );
        my $buf    = pack( "L", 0 );
        GetConsoleMode( $handle, $buf );
        $ORIG_WIN32_IN_MODE = unpack( "L", $buf );

        # Enable VT processing on the output handle so ANSI escapes render.
        my $out_handle = Acme::Parataxis::win32_get_osfhandle( fileno(STDOUT) );
        my $out_buf    = pack( "L", 0 );
        GetConsoleMode( $out_handle, $out_buf );
        my $out_mode = unpack 'L', $out_buf;
        my $ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004;
        SetConsoleMode( $out_handle, $out_mode | $ENABLE_VIRTUAL_TERMINAL_PROCESSING );
    }
    else {
        my $t = POSIX::Termios->new();
        $t->getattr(0);    # STDIN
        $ORIG_TERMIOS = $t;
    }
}

sub enable_raw_mode {
    save_termios() unless defined $ORIG_TERMIOS || defined $ORIG_WIN32_IN_MODE;
    if ( $^O eq 'MSWin32' ) {
        my $handle              = Acme::Parataxis::win32_get_osfhandle( fileno(STDIN) );
        my $ENABLE_LINE_INPUT   = 0x0002;
        my $ENABLE_ECHO_INPUT   = 0x0004;
        my $ENABLE_PROCESSED_IN = 0x0001;
        my $raw                 = $ORIG_WIN32_IN_MODE & ~( $ENABLE_LINE_INPUT | $ENABLE_ECHO_INPUT | $ENABLE_PROCESSED_IN );
        SetConsoleMode( $handle, $raw );
    }
    else {
        no strict 'subs';
        my $t = POSIX::Termios->new();
        $t->getattr(0);
        my $lflag = $t->getlflag();
        my $iflag = $t->getiflag();
        my $oflag = $t->getoflag();

        # Turn off: echo, canonical (line-buffered) mode, signal chars (ISIG
        # catches Ctrl+C/SUSP before we see the raw byte), newline echo.
        $lflag &= ~( ECHO | ICANON | ISIG | ECHONL );

eg/tui_bubbletea.pl  view on Meta::CPAN

}

# Terminal size platform-aware
sub get_terminal_size {
    if ( $^O eq 'MSWin32' ) {
        return _win32_terminal_size();
    }
    else {
        return _posix_terminal_size();
    }
}

sub _posix_terminal_size {

    # ioctl(fd, TIOCGWINSZ, &winsize)   TIOCGWINSZ = 0x5413 on Linux
    my $TIOCGWINSZ = 0x5413;

    # struct winsize { unsigned short ws_row; ws_col; ws_xpixel; ws_ypixel; }
    my $buf = "\0" x 8;
    if ( ioctl( \*STDOUT, $TIOCGWINSZ, $buf ) ) {
        my ( $rows, $cols, $xpix, $ypix ) = unpack( 'S4', $buf );
        return ( $rows, $cols ) if $rows > 0 && $cols > 0;
    }

    # Fallback: environment (often set by screen/tmux)
    my $h = $ENV{LINES}   // $ENV{COLUMNS};
    my $w = $ENV{COLUMNS} // $ENV{LINES};
    return ( $h // 24, $w // 80 );
}

sub _win32_terminal_size {

    # CONSOLE_SCREEN_BUFFER_INFO (22 bytes):
    #   COORD dwSize           (0:  4 bytes)
    #   COORD dwCursorPosition (4:  4 bytes)
    #   WORD  wAttributes      (8:  2 bytes)
    #   SMALL_RECT srWindow   (10: 8 bytes Left,Top,Right,Bottom as SHORTs)
    #   COORD dwMaximumSize    (18: 4 bytes)
    my $handle = Acme::Parataxis::win32_get_osfhandle( fileno(STDOUT) );
    my $csbi   = "\0" x 22;
    if ( GetConsoleScreenBufferInfo( $handle, $csbi ) ) {
        my ( $wl, $wt, $wr, $wb ) = unpack( 'ssss', substr( $csbi, 10, 8 ) );
        my $cols = $wr - $wl + 1;
        my $rows = $wb - $wt + 1;
        return ( $rows, $cols ) if $rows > 0 && $cols > 0;
    }

    # Fallback: environment or sensible defaults
    my $rows = $ENV{LINES}   // 24;
    my $cols = $ENV{COLUMNS} // 80;
    return ( $rows, $cols );
}

# ANSI helpers
sub hide_cursor  { print "\e[?25l" }
sub show_cursor  { print "\e[?25h" }
sub cls()        {"\e[H\e[2J"}
sub clear_screen { print cls }
sub move_to      { my ( $r, $c ) = @_; print "\e[${r};${c}H" }

# Message types (plain hashrefs bubbletea-style)
sub MsgKey    { { type => 'key',    key   => $_[0] } }
sub MsgTick   { { type => 'tick',   count => $_[0] } }
sub MsgResize { { type => 'resize', width => $_[0], height => $_[1] } }
sub MsgQuit   { { type => 'quit' } }

# Update / View  (pure functions over model)
sub Update ( $model, $msg ) {
    my %m = %$model;
    if ( $msg->{type} eq 'key' ) {
        my $key = $msg->{key};
        if ( !defined $key || $key eq "\cC" || $key eq 'q' ) {
            return \%m, MsgQuit();
        }
        elsif ( $key eq 'j' || $key eq "\e[B" ) {
            $m{cursor} = $m{cursor} < $#{ $m{items} } ? $m{cursor} + 1 : $m{cursor};
        }
        elsif ( $key eq 'k' || $key eq "\e[A" ) {
            $m{cursor} = $m{cursor} > 0 ? $m{cursor} - 1 : 0;
        }
        elsif ( $key eq ' ' || $key eq "\r" || $key eq "\n" ) {
            my $i = $m{cursor};
            $m{toggled}{$i} = !$m{toggled}{$i};
        }
    }
    elsif ( $msg->{type} eq 'resize' ) {
        $m{width}  = $msg->{width};
        $m{height} = $msg->{height};
    }
    elsif ( $msg->{type} eq 'tick' ) {
        $m{ticks} = $msg->{count};
    }
    return \%m, undef;
}

sub View ($model) {
    my $w    = $model->{width};
    my $h    = $model->{height};
    my $buf  = '';
    my $list = '';

    # title bar
    my $title = 'Parataxis TUI Demo (bubbletea-style)';
    $buf .= "\e[1;36m$title\e[0m\r\n";
    $buf .= "\e[2m${w}x${h}\e[0m\r\n";
    $buf .= "\r\n";

    # list items
    for my $i ( 0 .. $#{ $model->{items} } ) {
        my $item   = $model->{items}[$i];
        my $mark   = $model->{toggled}{$i}  ? "\e[32m[x]\e[0m" : "[ ]";
        my $cursor = $i == $model->{cursor} ? "\e[1;33m>\e[0m" : ' ';
        $list .= "$cursor $mark $item\r\n";
    }
    $buf .= $list;

    # status bar (pad to fill width)
    $buf .= "\r\n";
    my $status = sprintf 'ticks: %-5d | j/k move  space toggle  q quit', $model->{ticks};
    my $pad    = $w - length($status) - 2;
    $pad = 0 if $pad < 0;
    $buf .= "\e[7m ${status}\e[0m" . ( ' ' x $pad ) . "\r\n";

    # center a box to prove we see the real terminal size
    my $box_w = 32;
    my $box_h = 5;
    my $bx    = int( ( $w - $box_w ) / 2 ) - 2;
    my $by    = int( ( $h - $box_h ) / 2 ) - 2;
    $bx = 1 if $bx < 1;
    $by = 4 if $by < 4;    # below the list
    $buf .= "\e[${by};${bx}H\e[1;35m+\e[0m" . ( '-' x ( $box_w - 2 ) ) . "\e[1;35m+\e[0m";

    for my $row ( 1 .. $box_h - 2 ) {
        my $inner = $row == 2 ? ' Resize the terminal! ' : '';
        my $pad2  = $box_w - 2 - length($inner);
        $pad2 = 1 if $pad2 < 1;
        $buf .= "\e[" . ( $by + $row ) . ";${bx}H\e[1;35m|\e[0m${inner}" . ( ' ' x $pad2 ) . "\e[1;35m|\e[0m";
    }
    $buf .= "\e[" . ( $by + $box_h - 1 ) . ";${bx}H\e[1;35m+\e[0m" . ( '-' x ( $box_w - 2 ) ) . "\e[1;35m+\e[0m";
    return $buf;
}

# Fiber: raw keyboard input
# Windows INPUT_RECORD layout (20 bytes):
#   WORD  EventType        (offset  0, 2 bytes)
#   [2 bytes padding]
#   KEY_EVENT_RECORD union (offset  4, 16 bytes):
#     BOOL  bKeyDown       (offset  4, 4 bytes)
#     WORD  wRepeatCount   (offset  8, 2 bytes)
#     WORD  wVirtualKeyCode (offset 10, 2 bytes)
#     WORD  wVirtualScanCode(offset 12, 2 bytes)
#     CHAR  AsciiChar      (offset 14, 1 byte)
#     [1 byte padding]
#     DWORD dwControlKeyState (offset 16, 4 bytes)
sub input_fiber ( $ch, $quit ) {
    if ( $^O eq 'MSWin32' ) {

        # Windows: select() doesn't work on console handles, so we poll
        # with GetNumberOfConsoleInputEvents + ReadConsoleInputA via
        # kernel32 FFI.  WaitForSingleObject would be ideal but would block
        # the whole scheduler thread, so we use a tight await_sleep loop
        # and drain every pending event each cycle.
        my $handle = Acme::Parataxis::win32_get_osfhandle( fileno(STDIN) );



( run in 0.990 second using v1.01-cache-2.11-cpan-b554fb43c41 )