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;
}