Selkie.git | lib/Selkie/App/Internal/ | TerminalPlatform.rakumod


=begin pod

=head1 NAME

Selkie::App::Internal::TerminalPlatform - platform boundary for terminal lifecycle operations

=head1 DESCRIPTION

Internal implementation detail shared by C<Selkie::App> and
C<Selkie::Test::Snapshot>. All access to the POSIX controlling-terminal path,
termios ABI, flow-control command, and terminal-related signals is selected
here. Windows deliberately exposes no controlling-terminal or Raku signal
operations: its console is managed by notcurses.

=head2 Resize on Windows

Windows has no C<SIGWINCH>, and — unlike earlier releases — no resize I<input
event> either. notcurses drives the console in pure VT input mode, where the
byte stream is consumed with C<read()>; mixing Win32 C<INPUT_RECORD>s into that
stream can signal the console handle for a record C<read()> cannot consume,
wedging the input thread. C<ENABLE_WINDOW_INPUT> is therefore left off, and no
resize key is ever delivered.

C<Selkie::App>'s render loop instead polls the console viewport at a
deliberately low rate through C<windows-console-dimensions>, and only performs
the (comparatively expensive) resize handling when the reported geometry
actually differs from the current one.

=head1 SUBROUTINES

=head2 windows-console-dimensions

    my @dims = windows-console-dimensions();   # (rows, cols), or () on failure

Read-only query of the Windows console viewport, returning a two-element
C<(rows, cols)> list. Returns an B<empty> list on any platform other than
Windows, when the standard output handle is not a console (a redirected or
piped stdout), or when the geometry comes back degenerate — so callers must
check C<.elems == 2> rather than assuming two values.

Unlike C<notcurses_refresh>, this neither invalidates notcurses's
rendered-frame state nor emits any terminal output, which is what makes it safe
to call on a timer.

=end pod

unit module Selkie::App::Internal::TerminalPlatform;

use NativeCall;

use Selkie::App::Internal::PosixFD;

my constant TERMINAL-PATH = '/dev/tty';
# Opaque storage only: struct termios is 60 bytes on supported glibc/musl
# targets and 72 bytes on macOS arm64. Selkie never reads its fields.
my constant TERMIOS-BUF-BYTES = 256;
my constant TCSANOW = 0;
my constant O_RDONLY = 0;
my constant STD-OUTPUT-HANDLE = -11;

# Flattened CONSOLE_SCREEN_BUFFER_INFO. Every member has two-byte alignment,
# so this repr matches the Win32 structure without nested CStruct marshalling.
class ConsoleScreenBufferInfo is repr('CStruct') {
    has int16  $.size-x;
    has int16  $.size-y;
    has int16  $.cursor-x;
    has int16  $.cursor-y;
    has uint16 $.attributes;
    has int16  $.window-left;
    has int16  $.window-top;
    has int16  $.window-right;
    has int16  $.window-bottom;
    has int16  $.maximum-x;
    has int16  $.maximum-y;
}

# This pure selector is exported only for platform-contract tests. Signal names
# stay as strings until the selected, host-valid branch is used.
our sub terminal-platform-spec(Bool:D $is-win --> Map:D) is export(:test) {
    $is-win
        ?? Map.new(
            controlling-terminal => False,
            resize-signals       => ().List,
            crash-signals        => ().List,
        )
        !! Map.new(
            controlling-terminal => True,
            resize-signals       => <SIGWINCH>.List,
            crash-signals        => <SIGABRT SIGTERM SIGHUP SIGQUIT>.List,
        );
}

our constant IS-WINDOWS is export = $*DISTRO.is-win;
my constant PLATFORM-SPEC = terminal-platform-spec(IS-WINDOWS);

# POSIX-only. NativeCall resolves these lazily, and every public operation
# returns on Windows before it can reach a binding or the /dev/tty path.
# open(2) is variadic, but the mode argument is unused without O_CREAT; the two
# named arguments have the same ABI on all POSIX targets supported by Selkie.
sub c-tty-open(Str, int32 --> int32)                   is native(Str) is symbol('open')      { * }
sub c-tcgetattr(int32, CArray[uint8] --> int32)        is native(Str) is symbol('tcgetattr') { * }
sub c-tcsetattr(int32, int32, CArray[uint8] --> int32) is native(Str) is symbol('tcsetattr') { * }
sub win-get-std-handle(int32 --> Pointer)
    is native('kernel32') is symbol('GetStdHandle') { * }
sub win-get-console-screen-buffer-info(Pointer, ConsoleScreenBufferInfo --> int32)
    is native('kernel32') is symbol('GetConsoleScreenBufferInfo') { * }

# Read-only ConPTY/console viewport query. Unlike notcurses_refresh this does
# not invalidate notcurses's rendered-frame state or emit terminal output.
our sub windows-console-dimensions(--> List:D) is export {
    return ().List unless IS-WINDOWS;
    my $handle = try win-get-std-handle(STD-OUTPUT-HANDLE);
    return ().List unless $handle.defined;
    my $info = ConsoleScreenBufferInfo.new;
    my $ok = try win-get-console-screen-buffer-info($handle, $info);
    return ().List unless $ok.defined && $ok != 0;
    my Int $rows = $info.window-bottom - $info.window-top + 1;
    my Int $cols = $info.window-right - $info.window-left + 1;
    return ().List unless $rows > 0 && $cols > 0;
    ($rows, $cols).List;
}

our sub capture-controlling-terminal-state(--> CArray[uint8]) is export {
    return CArray[uint8] if IS-WINDOWS;
    return CArray[uint8] unless TERMINAL-PATH.IO.e;

    my $fd = try c-tty-open(TERMINAL-PATH, O_RDONLY);
    return CArray[uint8] unless $fd.defined && $fd >= 0;

    my $state = CArray[uint8].allocate(TERMIOS-BUF-BYTES);
    my $result = try c-tcgetattr($fd, $state);
    my $ = try posix-fd-close($fd);
    $result.defined && $result == 0 ?? $state !! CArray[uint8];
}

our sub restore-controlling-terminal-state(CArray[uint8] $state --> Nil) is export {
    return if IS-WINDOWS;
    return unless $state.defined;
    return unless TERMINAL-PATH.IO.e;

    my $fd = try c-tty-open(TERMINAL-PATH, O_RDONLY);
    return unless $fd.defined && $fd >= 0;

    my $ = try c-tcsetattr($fd, TCSANOW, $state);
    my $ = try posix-fd-close($fd);
    Nil;
}

our sub write-controlling-terminal(Str:D $text --> Nil) is export {
    return if IS-WINDOWS;
    return unless TERMINAL-PATH.IO.e;

    my $tty = try open(TERMINAL-PATH, :w);
    return without $tty;
    my $ = try $tty.print($text);
    my $ = try $tty.close;
    Nil;
}

our sub disable-controlling-terminal-flow-control(--> Nil) is export {
    return if IS-WINDOWS;
    return unless TERMINAL-PATH.IO.e;

    # Assigned rather than sunk: Proc throws from sink when stty fails, and a
    # statement-level sink would happen outside try.
    my $ = try shell 'stty -ixon -ixoff < /dev/tty 2>/dev/null';
    Nil;
}

our sub terminal-resize-signals(--> List:D) is export {
    PLATFORM-SPEC<resize-signals>.map({ Signal(Signal.enums{$_}) }).List;
}

our sub terminal-crash-signals(--> List:D) is export {
    PLATFORM-SPEC<crash-signals>.map({ Signal(Signal.enums{$_}) }).List;
}