From c6ead68a89534569ec4bfffa6ae8e1e0d151a6a0 Mon Sep 17 00:00:00 2001 From: Julian Noble Date: Fri, 3 Jul 2026 19:15:43 +1000 Subject: [PATCH] probe-byte store unification + first-use settling for all query paths opunk::console (0.2.0): - probe-byte waiting store changed from dict to array (::opunk::console::waiting_chunks) and made pluggable via waiting_chunks_arrayvar: integrating layers redirect it to their established cooperative store so bytes consumed by opunk::Console probe reads stay visible to active readers. The class remains free of punk::console dependencies - redirection is the integrator's act. punk::console (0.1.6): - default_console redirects the opunk::console store to punk::console::input_chunks_waiting (closing the split-store hazard that previously made opunk-level probes invisible to the punk repl reader) - get_ansi_response_payload performs first-use active settling for persistable console specs, guarded by a recursion latch around settle_can_respond's own probe: every query path (get_cursor_pos, get_device_status, ...) now pays at most one settling probe instead of a fresh timeout per call on unsettled ambiguous consoles punk::repl (0.1.6): - repl::init routes default console construction through punk::console::default_console (deduplicates creation logic and gains the store unification) tests: store-unification and get_cursor_pos first-use settling cases in consolespec.test (zero-emission and persistence asserts); consoleclass.test store assertions converted to array form. Console suites 25/25 + 9/9; punkexe 6/6, goals 8/8; full suite green except pre-existing exec-14.3. Assisted-by: harness=claude; primary-model=claude-fable-5; api-location=anthropic.com --- .../modules/opunk/console-0.2.0.tm | 372 ++ src/bootsupport/modules/punk/console-0.1.6.tm | 4792 +++++++++++++++++ src/bootsupport/modules/punk/repl-0.1.6.tm | 4222 +++++++++++++++ src/modules/opunk/AGENTS.md | 1 + src/modules/opunk/console-999999.0a1.0.tm | 27 +- src/modules/opunk/console-buildversion.txt | 3 +- src/modules/punk/console-999999.0a1.0.tm | 47 +- src/modules/punk/console-buildversion.txt | 3 +- src/modules/punk/repl-999999.0a1.0.tm | 7 +- src/modules/punk/repl-buildversion.txt | 3 +- .../modules/opunk/console-0.2.0.tm | 372 ++ .../bootsupport/modules/punk/console-0.1.6.tm | 4792 +++++++++++++++++ .../bootsupport/modules/punk/repl-0.1.6.tm | 4222 +++++++++++++++ .../modules/opunk/console-0.2.0.tm | 372 ++ .../bootsupport/modules/punk/console-0.1.6.tm | 4792 +++++++++++++++++ .../bootsupport/modules/punk/repl-0.1.6.tm | 4222 +++++++++++++++ .../testsuites/console/consoleclass.test | 16 +- .../testsuites/console/consolespec.test | 53 + .../modules/opunk/console-0.2.0.tm | 372 ++ .../modules/punk/console-0.1.6.tm | 4792 +++++++++++++++++ .../_vfscommon.vfs/modules/punk/repl-0.1.6.tm | 4222 +++++++++++++++ 21 files changed, 37673 insertions(+), 31 deletions(-) create mode 100644 src/bootsupport/modules/opunk/console-0.2.0.tm create mode 100644 src/bootsupport/modules/punk/console-0.1.6.tm create mode 100644 src/bootsupport/modules/punk/repl-0.1.6.tm create mode 100644 src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/opunk/console-0.2.0.tm create mode 100644 src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/console-0.1.6.tm create mode 100644 src/project_layouts/custom/_project/punk.project-0.1/src/bootsupport/modules/punk/repl-0.1.6.tm create mode 100644 src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/opunk/console-0.2.0.tm create mode 100644 src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/console-0.1.6.tm create mode 100644 src/project_layouts/custom/_project/punk.shell-0.1/src/bootsupport/modules/punk/repl-0.1.6.tm create mode 100644 src/vfs/_vfscommon.vfs/modules/opunk/console-0.2.0.tm create mode 100644 src/vfs/_vfscommon.vfs/modules/punk/console-0.1.6.tm create mode 100644 src/vfs/_vfscommon.vfs/modules/punk/repl-0.1.6.tm diff --git a/src/bootsupport/modules/opunk/console-0.2.0.tm b/src/bootsupport/modules/opunk/console-0.2.0.tm new file mode 100644 index 00000000..2432c1c7 --- /dev/null +++ b/src/bootsupport/modules/opunk/console-0.2.0.tm @@ -0,0 +1,372 @@ +# -*- tcl -*- +# Maintenance Instruction: leave the 999999.xxx.x as is and use punkshell 'dev make' or bin/punkmake to update from -buildversion.txt +# module template: shellspy/src/decktemplates/vendor/punk/modules/template_module-0.0.4.tm +# +# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem. +# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository. +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +# (C) 2026 +# +# @@ Meta Begin +# Application opunk::console 0.2.0 +# Meta platform tcl +# Meta license BSD +# @@ Meta End + + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +## Requirements +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ + +package require Tcl 8.6- + +package require voo +package require punk::args + +#opunk::Console - a voo (value-based) representation of a console: an in/out channel pair claimed to +#belong to one responding endpoint, together with settled capability facts about that endpoint. +# +#Motivation (see punk::console get_size / get_ansi_response_payload history): +#ANSI query mechanisms assume the input channel receives what the output channel's device emits in +#response. When stdio is redirected that pairing silently breaks - queries go to a terminal whose +#replies land in some other process's input. A console object makes the pairing an explicit, +#construction-time property instead of a per-query-site guess. +# +#Design notes: +# - Objects are voo values (plain lists). Consoles are entities, not values - so canonical instances +# are anchored at well-known namespace variables (::opunk::console::instances::) and the value +# semantics serve as cheap snapshots. Mutation (settling capabilities) goes through the anchor via +# the lowercase ::opunk::console wrapper procs. +# - The class is -virtual: slot 0 of each value carries the concrete class namespace, so channel +# environments that respond like terminals but aren't platform consoles can subclass +# (-extends ::opunk::Console) and override the capability/size methods, with existing holders of +# console values dispatching correctly. +# - This first cut is standalone and passive: capability detection is heuristic (channel options, +# probed eof, terminal env hints) and size uses chan -winsize else the default size. Active ANSI +# query probing arrives with punk::console integration (which will delegate here rather than the +# class depending on punk::console). + +tcl::namespace::eval ::opunk::console { + variable PUNKARGS + + #well-known cooperative store for bytes consumed by probe reads on pipe-like channels: + #an array keyed by channel name -> list of chunks. Readers taking over a channel should consume + #and clear any entry for that channel first. + #The store is PLUGGABLE via waiting_chunks_arrayvar: environments with an established cooperative + #store (e.g punk::console::input_chunks_waiting, which the punk repl reader consults) redirect it + #so probe-consumed bytes are never invisible to the active reader. The class itself stays free of + #any punk::console dependency - redirection is performed by the integrating layer. + variable waiting_chunks + if {![array exists waiting_chunks]} { + array set waiting_chunks {} + } + variable waiting_chunks_arrayvar + if {![info exists waiting_chunks_arrayvar]} { + set waiting_chunks_arrayvar ::opunk::console::waiting_chunks + } +} + +voo::class ::opunk::Console -virtual { + private { + #o_ prefix per opunk voo convention (distinguishes fields from locals in -update methods, + #and matches tclOO-style implementations elsewhere for easier code movement). + #Note the class is -virtual: slot 0 of the object value is the concrete class namespace tag. + string_t o_in stdin + string_t o_out stdout + string_t o_terminal_class "" ;#e.g windows-console, tty, pipe - "" until classified + list_t o_size_mechanism [list] ;#preferred size mechanism try-order - empty until timed/settled + dict_t o_default_size [dict create columns 80 rows 24] + } + public { + #settled response capability: -1 unknown, 0 cannot respond, 1 can respond. + #public accessors (get.o_can_respond / set.o_can_respond) so the anchored-mutation wrappers in + #::opunk::console can settle it without reaching into private my.* accessors. + int_t o_can_respond -1 + + method in {} { + my.get.o_in $this + } + method out {} { + my.get.o_out $this + } + method channels {} { + list [my.get.o_in $this] [my.get.o_out $this] + } + method terminal_class {} { + my.get.o_terminal_class $this + } + method default_size {} { + my.get.o_default_size $this + } + + #Certainty test: is the input channel closed or at eof (and so can never deliver a response)? + #A consumed/half-closed pipe may not flag eof until a read is attempted, so pipe-like channels + #get a non-blocking 1-byte probe. A probed byte is preserved in the cooperative waiting store + #(array named by ::opunk::console::waiting_chunks_arrayvar - redirectable by the integrating + #layer to e.g punk::console::input_chunks_waiting so active readers can find it). + #Console/tty channels are never probed (must not consume a keystroke). + method at_eof {} -virtual { + if {[llength $this] < 7} { + error "invalid object $this" + } + set in [my.get.o_in $this] + if {[catch {chan eof $in} is_eof]} { + return 1 ;#closed/invalid channel - unusable + } + if {$is_eof} { + return 1 + } + if {[catch {chan configure $in} conf]} { + return 1 + } + if {[dict exists $conf -inputmode] || [dict exists $conf -mode]} { + return 0 + } + if {[catch { + set prior_blocking [dict get $conf -blocking] + chan configure $in -blocking 0 + set probe [read $in 1] + chan configure $in -blocking $prior_blocking + }]} { + return 1 + } + if {$probe ne ""} { + upvar #0 $::opunk::console::waiting_chunks_arrayvar wchunks + lappend wchunks($in) $probe + } + return [chan eof $in] + } + + #Heuristic: could the input channel be connected to a terminal able to answer queries? + #Returns 0 only when reasonably confident it cannot. mintty without winpty presents a + #terminal's stdin as a plain pipe, so env hints (MSYSTEM, TERM_PROGRAM) prevent false + #negatives there at the cost of false positives for genuinely piped-but-open input. + method is_console_or_tty {} -virtual { + if {[llength $this] < 7} { + error "invalid object $this" + } + set in [my.get.o_in $this] + if {[catch {chan configure $in} conf]} { + return 0 + } + if {[dict exists $conf -inputmode]} { + return 1 + } + if {[dict exists $conf -mode]} { + return 1 + } + if {[at_eof $this]} { + return 0 + } + if {[info exists ::env(TERM_PROGRAM)] || [info exists ::env(MSYSTEM)]} { + return 1 + } + return 0 + } + + #Response capability: settled value if known, else computed heuristically (without settling - + #this method is pure; use ::opunk::console::can_respond to settle an anchored instance + #heuristically, or punk::console::settle_can_respond for active-probe settling - the + #probe machinery lives in punk::console so this class stays free of that dependency). + method can_respond {} -virtual { + if {[llength $this] < 7} { + error "invalid object $this" + } + set settled [get.o_can_respond $this] + if {$settled != -1} { + return $settled + } + return [is_console_or_tty $this] + } + + #Size of the console: chan -winsize on the output channel when available (fast, no query), + #else the configured default size. ANSI cursor-report mechanisms are deliberately absent from + #this standalone class - they arrive via punk::console integration and subclasses. + method size {} -virtual { + if {[llength $this] < 7} { + error "invalid object $this" + } + set out [my.get.o_out $this] + if {![catch {chan configure $out} oconf]} { + if {[dict exists $oconf -winsize]} { + lassign [dict get $oconf -winsize] cols lines + if {[string is integer -strict $cols] && [string is integer -strict $lines]} { + return [dict create columns $cols rows $lines] + } + } + } + return [my.get.o_default_size $this] + } + } + + constructor {in out} { + #new() supplies the class defaults including the virtual class tag at slot 0 + set obj [new()] + my.set.o_in obj $in + my.set.o_out obj $out + return $obj + } +} + +#make introspection (with i and punk::ns::cmdinfo) easier by making the class namespace an ensemble - we can still access it as ::opunk::Console +namespace eval ::opunk::Console { + namespace ensemble create +} + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +# Anchored instances - the 'consoles are entities' layer +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +tcl::namespace::eval ::opunk::console { + tcl::namespace::export {[a-z]*} ;# Convention: export all lowercase + + namespace eval instances { + #one variable per anchored console instance - the canonical location of each console object. + #Access via the sibling procs; methods needing to mutate an instance go through these anchors. + } + + proc create {name in out} { + if {![string is wordchar -strict $name]} { + error "opunk::console::create name must be alphanumeric/underscore, got '$name'" + } + set [namespace current]::instances::$name [::opunk::Console::new $in $out] + return [namespace current]::instances::$name + } + proc instancevar {name} { + set v [namespace current]::instances::$name + if {![info exists $v]} { + error "opunk::console: no anchored console instance named '$name' (known: [names])" + } + return $v + } + proc instance {name} { + set [instancevar $name] + } + proc names {} { + set result [list] + foreach v [info vars [namespace current]::instances::*] { + lappend result [namespace tail $v] + } + return $result + } + proc forget {name} { + unset [instancevar $name] + return + } + + #settle-and-cache response capability on the anchored instance. + #The class method is pure (value-in) - this wrapper owns the mutation via the anchor. + proc can_respond {name} { + upvar #0 [instancevar $name] obj + set settled [::opunk::Console::get.o_can_respond $obj] + if {$settled != -1} { + return $settled + } + set r [::opunk::Console::can_respond $obj] + ::opunk::Console::set.o_can_respond obj $r + return $r + } + proc size {name} { + ::opunk::Console::size [instance $name] + } +} +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ + + +# == === === === === === === === === === === === === === === +# Sample 'about' function with punk::args documentation +# == === === === === === === === === === === === === === === +tcl::namespace::eval ::opunk::console { + variable PUNKARGS + variable PUNKARGS_aliases + + lappend PUNKARGS [list { + @id -id "(package)opunk::console" + @package -name "opunk::console" -help\ + "voo class ::opunk::Console representing a console as an in/out channel pair with settled + response-capability facts, plus anchored canonical instances under ::opunk::console::instances." + }] + + namespace eval argdoc { + proc package_name {} { + return opunk::console + } + proc about_topics {} { + set topic_funs [info commands [namespace current]::get_topic_*] + set about_topics [list] + foreach f $topic_funs { + set tail [namespace tail $f] + lappend about_topics [string range $tail [string length get_topic_] end] + } + return [lsort $about_topics] + } + proc default_topics {} {return [list Description *]} + + proc get_topic_Description {} { + punk::args::lib::tstr [string trim { + package opunk::console + voo class for consoles as channel-pair objects with anchored canonical instances. + } \n] + } + proc get_topic_License {} { + return "BSD" + } + proc get_topic_Version {} { + return "$::opunk::console::version" + } + proc get_topic_Contributors {} { + set authors {{"Julian Noble" "julian@precisium.com.au"}} + set contributors "" + foreach a $authors { + append contributors $a \n + } + if {[string index $contributors end] eq "\n"} { + set contributors [string range $contributors 0 end-1] + } + return $contributors + } + proc get_topic_classes {} { + punk::args::lib::tstr -return string { + opunk::Console "virtual class for consoles as in/out channel pairs" + } + } + } + + set overrides [dict create] + dict set overrides @id -id "::opunk::console::about" + dict set overrides @cmd -name "opunk::console::about" + dict set overrides @cmd -help [string trim [punk::args::lib::tstr { + About opunk::console + }] \n] + dict set overrides topic -choices [list {*}[opunk::console::argdoc::about_topics] *] + dict set overrides topic -choicerestricted 1 + dict set overrides topic -default [opunk::console::argdoc::default_topics] + set newdef [punk::args::resolved_def -antiglobs -package_about_namespace -override $overrides ::punk::args::package::standard_about *] + lappend PUNKARGS [list $newdef] + proc about {args} { + package require punk::args + set argd [punk::args::parse $args withid ::opunk::console::about] + lassign [dict values $argd] _leaders opts values _received + punk::args::package::standard_about -package_about_namespace ::opunk::console::argdoc {*}$opts {*}[dict get $values topic] + } +} +# end of sample 'about' function +# == === === === === === === === === === === === === === === + + +# ----------------------------------------------------------------------------- +# register namespace(s) to have PUNKARGS,PUNKARGS_aliases variables checked +# ----------------------------------------------------------------------------- +namespace eval ::punk::args::register { + #use fully qualified so 8.6 doesn't find existing var in global namespace + lappend ::punk::args::register::NAMESPACES ::opunk::console ::opunk::Console +} +# ----------------------------------------------------------------------------- + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +## Ready +package provide opunk::console [tcl::namespace::eval ::opunk::console { + variable pkg opunk::console + variable version + set version 0.2.0 +}] +return diff --git a/src/bootsupport/modules/punk/console-0.1.6.tm b/src/bootsupport/modules/punk/console-0.1.6.tm new file mode 100644 index 00000000..eb132281 --- /dev/null +++ b/src/bootsupport/modules/punk/console-0.1.6.tm @@ -0,0 +1,4792 @@ +# -*- tcl -*- +# Maintenance Instruction: leave the 999999.xxx.x as is and use 'pmix make' or src/make.tcl to update from -buildversion.txt +# +# Please consider using a BSD or MIT style license for greatest compatibility with the Tcl ecosystem. +# Code using preferred Tcl licenses can be eligible for inclusion in Tcllib, Tklib and the punk package repository. +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +# (C) 2023 +# +# @@ Meta Begin +# Application punk::console 0.1.6 +# Meta platform tcl +# Meta license +# @@ Meta End + + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +# doctools header +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +#*** !doctools +#[manpage_begin punkshell_module_punk::console 0 0.1.6] +#[copyright "2024"] +#[titledesc {punk console}] [comment {-- Name section and table of contents description --}] +#[moddesc {punk console}] [comment {-- Description at end of page heading --}] +#[require punk::console] +#[keywords module console terminal] +#[description] +#[para] + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ + +#*** !doctools +#[section Overview] +#[para] overview of punk::console +#[subsection Concepts] +#[para] + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +## Requirements +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ + +#*** !doctools +#[subsection dependencies] +#[para] packages used by punk::console +#[list_begin itemized] + +package require Tcl 8.6- + +#---------------------------------------------------- +#Although we need to be in an environment with Thread available to use punk::console, +# we don't want to require Thread as a hard dependency in the interp we're running in. +# We should be able to provide wrappers such that thread features we need can be used via aliases into the current interp. +#TODO. +package require Thread ;#tsv required to sync is_raw +#---------------------------------------------------- +package require punk::ansi +package require punk::args +#*** !doctools +#[item] [package {Tcl 8.6-}] +#[item] [package {Thread}] +#[item] [package {punk::ansi}] +#[item] [package {punk::args}] + + +#*** !doctools +#[list_end] + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ + +#*** !doctools +#[section API] + + + +#see https://learn.microsoft.com/en-us/windows/console/classic-vs-vt +#https://learn.microsoft.com/en-us/windows/console/creating-a-pseudoconsole-session + +# ++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +++ +namespace eval punk::console { + #*** !doctools + #[subsection {Namespace punk::console}] + #[para] + + #*** !doctools + #[list_begin definitions] + + variable PUNKARGS + + variable tabwidth 8 ;#default only - will attempt to detect and set to that configured in terminal + #Note that windows terminal cooked mode seems to use 8 for interactive use even if set differently + #e.g typing tab characters may still be echoed 8-spaced while writing to stdout my obey the terminal's tab stops. + variable has_twapi 0 + if {"windows" eq $::tcl_platform(platform)} { + set has_twapi [expr {! [catch {package require twapi}]}] + } + + + variable previous_stty_state_stdin "" + variable previous_stty_state_stdout "" + variable previous_stty_state_stderr "" + + #variable is_raw 0 + if {![tsv::exists console is_raw]} { + tsv::set console is_raw 0 + } + + variable input_chunks_waiting + if {![info exists input_chunks_waiting(stdin)]} { + set input_chunks_waiting(stdin) [list] + } + variable ansi_response_chunk ;#array keyed on callid + variable ansi_response_wait ;#array keyed on callid + array set ansi_response_wait {} + variable ansi_response_queue [list];#list of callids + variable ansi_response_queuedata ;#dict keyed on callid - with function params + + # -- + variable ansi_available -1 ;#default -1 for unknown. Leave it this way so test for ansi support is run. + #-1 still evaluates to true - as the modern assumption for ansi availability is true + #only false if ansi_available has been set 0 by test_can_ansi + #support ansistrip for legacy windows terminals + # -- + + variable ansi_wanted 2 ;#2 for default assumed yes, will be set to -1 for automatically unwanted when ansi unavailable values of 0 or 1 won't be autoset + + + + #punk::console namespace - contains *directly* acting functions - some based on ansi escapes from the 'ansi' sub namespace, some on local system calls or executable calls wrapped in the 'local' sub namespace + #directly acting means they write to stdout to cause the console to perform the action, or they perform the action immediately via other means. + #punk::console::ansi contains a subset of punk::ansi, but with emission to stdout as opposed to simply returning the ansi sequence. + #punk::console::local functions are used by punk::console commands when there is no ansi equivalent + #ansi escape sequences are possibly preferable esp if terminal is remote to process running punk::console + # punk::local commands may be more performant in some circumstances where console is directly attached, but it shouldn't be assumed. e.g ansi::titleset outperforms local::titleset on windows with twapi. + + namespace eval local { + #non-ansi terminal/console control functions + #e.g external utils system API's. + namespace export * + } + namespace eval argdoc { + variable PUNKARGS + #non-colour SGR codes + set I "\x1b\[3m" ;# [a+ italic] + set NI "\x1b\[23m" ;# [a+ noitalic] + set B "\x1b\[1m" ;# [a+ bold] + set N "\x1b\[22m" ;# [a+ normal] + set T "\x1b\[1\;4m" ;# [a+ bold underline] + set NT "\x1b\[22\;24m\x1b\[4:0m" ;# [a+ normal nounderline] + interp alias "" ::overtype::example "" ::punk::args::helpers::example + } + + + + + #review - document and decide granularity required. should we enable/disable more than one at once? + proc enable_mouse {} { + puts -nonewline stdout \x1b\[?1000h + puts -nonewline stdout \x1b\[?1003h + puts -nonewline stdout \x1b\[?1015h + puts -nonewline stdout \x1b\[?1006h + flush stdout + } + proc disable_mouse {} { + puts -nonewline stdout \x1b\[?1000l + puts -nonewline stdout \x1b\[?1003l + puts -nonewline stdout \x1b\[?1015l + puts -nonewline stdout \x1b\[?1006l + flush stdout + } + proc enable_mouse_sgr {} { + puts -nonewline stdout \x1b\[?1000h\x1b\[?1003h\x1b\[?1016h + flush stdout + } + proc disable_mouse_sgr {} { + puts -nonewline stdout \x1b\[?1000l\x1b\[?1003l\x1b\[?1016l + flush stdout + } + proc enable_bracketed_paste {} { + puts -nonewline stdout \x1b\[?2004h + } + proc disable_bracketed_paste {} { + puts -nonewline stdout \x1b\[?2004l + } + proc start_application_mode {} { + #need loop to read events? + puts -nonewline stdout \x1b\[?1049h ;#alt screen + enable_mouse + #puts -nonewline stdout \x1b\[?25l ;#hide cursor + puts -nonewline stdout \x1b\[?1003h\n + enable_bracketed_paste + } + #todo stop_application_mode {} {} + + proc mode {{raw_or_line query}} { + #variable is_raw + variable ansi_available + set raw_or_line [string tolower $raw_or_line] + if {$raw_or_line eq "query"} { + if {[tsv::get console is_raw]} { + return "raw" + } else { + return "line" + } + } elseif {$raw_or_line eq "raw"} { + if {[catch { + punk::console::enableRaw + } errM]} { + puts stderr "Warning punk::console::enableRaw failed - $errM" + } + if {[can_ansi]} { + punk::console::enableVirtualTerminal both + } + } elseif {$raw_or_line eq "line"} { + #review -order. disableRaw has memory from enableRaw.. but but for line mode we want vt disabled - so call it after disableRaw (?) + if {[catch { + punk::console::disableRaw + } errM]} { + puts stderr "Warning punk::console::disableRaw failed - $errM" + } + if {[can_ansi]} { + punk::console::disableVirtualTerminal input ;#default readline arrow behaviour etc + punk::console::enableVirtualTerminal output ;#display/use ansi codes + } + } else { + error "punk::console::mode expected 'raw' or 'line' or default value 'query'" + } + } + + namespace eval internal { + + proc abort_if_loop {{failmsg ""}} { + #obsolete + #puts "il1 [info level 1]" + #puts "thisproc: [lindex [info level 0] 0]" + set would_loop [uplevel 1 {expr {[string match *loopavoidancetoken* [info body [namespace tail [lindex [info level 0] 0]]]]}}] + #puts "would_loop: $would_loop" + if {$would_loop} { + set procname [uplevel 1 {namespace tail [lindex [info level 0] 0]}] + if {$failmsg eq ""} { + set errmsg "[namespace current] Failed to redefine procedure $procname" + } else { + set errmsg $failmsg + } + error $errmsg + } + } + proc define_windows_procs {} { + + + + + } + + + #we have punk::args as a requirement at the top of this module + #so we can use punk::args::define instead of lappend PUNKARGS + #we do this here because this can be called before the PUNKARGS definitions have been processed. + punk::args::define { + @id -id ::punk::console::internal::get_ansi_response_payload + @cmd -name punk::console::internal::get_ansi_response_payload -help\ + "Terminal query helper. + Captures the significant portion (payload as defined by + supplied capturingendregex capture groups) of the input + channel's response to a query placed on the output channel. + Usually this means a write to stdout with a response on + stdin. + This function uses a 'chan event' read handler function + ::punk::console::internal::ansi_response_handler_regex to + read the input channel character by character to ensure it + doesn't overconsume input. + + It can run cooperatively with the punk::repl stdin reader + or other readers if done carefully. + The mechanism to run while other readers are active involves + disabling and re-enabling installed 'chan event' handlers + and possibly using a shared namespace variable + (::punk::console::input_chunks_waiting) to ensure all data + gets to the right handler. (unread data on input prior to this + function being called) + Not fully documented. (source diving required -see punk::repl) + " + @opts + -ignoreok -type boolean -default 0 -help\ + "Experimental/debug + ignore the regex match 'ok' response + and keep going." + -return -type string -default payload -choices {payload dict} -choicelabels { + dict + "dict with keys prefix,response,payload,all" + } -help\ + "Return format" + + -console -default {stdin stdout} -type list -help\ + "Console specification: a 2-element {in out} channel list, an anchored + opunk::console instance name, or an ::opunk::Console object value. + An object with settled can_respond 0 causes an immediate error + rather than emitting a query that cannot be answered." + -passthrough -default "none" -choices {none tmux auto} -choicecolumns 1 -choicelabels { + none + { ANSI sent without any passthrough wrapping. + A terminal multiplexer such as tmux,screen,zellij may + not pass the request through to the underlying terminal(s) + This is the recommended/normal value for the option.} + tmux + { Wrap ANSI sequence with tmux passthrough sequence. + \x1bPtmux\;\x1b\\ + Note that a tmux session could be connected to multiple + terminals (perhaps of different types) - in which case multiple + responses may be received in a non-deterministic order. + Passthrough should generally be avoided except for debug/test + purposes. + } + auto + { Use existence of ::env(TMUX) to detect tmux and + send tmux passthrough sequence. + Not recommended except for debug/test purposes. + } + } + #todo - allow setting this default from a global config variable. + -expected_ms -default 500 -type integer -help\ + "Expected number of ms for response from terminal. + 100ms is usually plenty for a local terminal and a + basic query such as cursor position. + However on a busy machine a higher timeout may be + prudent. + Some terminals may require a longer timeout." + @values -min 2 -max 2 + query -type string -help\ + {ANSI sequence such as \x1b\[?6n which + should elicit a response by the terminal + on stdin} + capturingendregex -type string -help\ + {capturingendregex should capture ANY prefix, whole escape match - and a subcapture + of the data we're interested in; and match at end of string. + ie {(.*)(ESC(info)end)$} + e.g {(.*)(\x1bP44!~([:alnum:])\x1b\\)$} + we expect 4 results from regexp -indices -inline (overallmatch, prefix, wholeescape,info)} + } + #todo - check capturingendregex value supplied has appropriate captures and tail-anchor + proc get_ansi_response_payload {args} { + #we pay a few 10s of microseconds to use punk::args::parse (on the happy path) + #seems reasonable for the flexibility in this case. + set argd [punk::args::parse $args -cache 1 withid ::punk::console::internal::get_ansi_response_payload] + lassign [dict values $argd] leaders opts values received + + set inoutchannels [dict get $opts -console] + set expected [dict get $opts -expected_ms] + set ignoreok [dict get $opts -ignoreok] + set returntype [dict get $opts -return] + set passthrough [dict get $opts -passthrough] + set query [dict get $values query] + set capturingendregex [dict get $values capturingendregex] + + #-console accepts a channel pair, an anchored opunk::console instance name, or an + #::opunk::Console object value - resolve to the canonical channel pair (and object if any) + set consolespec $inoutchannels + set cinfo [punk::console::console_spec_resolve $inoutchannels] + set inoutchannels [list [dict get $cinfo in] [dict get $cinfo out]] + lassign $inoutchannels input output + set governing_obj [dict get $cinfo object] + if {$governing_obj ne ""} { + set governing_cr [::opunk::Console::get.o_can_respond $governing_obj] + if {$governing_cr == -1 && !$::punk::console::can_respond_settling} { + #first-use active settling so unsettled ambiguous consoles pay one probe rather + #than a fresh timeout per query. Persistable specs only (anchored instance name, + #or the auto-attached default instance) - the latch prevents recursion when the + #settle probe itself passes through here. + if {[llength $consolespec] == 1 || ([info exists ::opunk::console::instances::default] && $governing_obj eq [set ::opunk::console::instances::default])} { + set governing_cr [punk::console::settle_can_respond $consolespec] + } + } + if {$governing_cr == 0} { + error "punk::console::get_ansi_response_payload console object for '$input' '$output' is settled as unable to respond (can_respond=0) - refusing to emit query" + } + } + + #A closed or eof input channel can never deliver a terminal response - fail fast rather than + #emitting the query and waiting for a reply that cannot arrive (e.g piped/redirected stdin that + #has already been consumed). Deliberately only tests certainty of failure (with a probe read for + #deferred pipe eof) - a pipe that is still open could be mintty without winpty presenting a + #terminal's stdin as a pipe (see is_input_console_or_tty) + if {[::punk::console::input_at_eof $input]} { + error "punk::console::get_ansi_response_payload input channel '$input' is closed or at eof - cannot receive terminal response" + } + + #chunks from input that need to be handled by readers + upvar ::punk::console::input_chunks_waiting input_chunks_waiting + + #we need to cooperate with other stdin/$input readers and put data here if we overconsume. + #Main repl reader may be currently active - or may be inactive. + #This call could come from within code called by the main reader - or from user code running while main read-loop is temporarily disabled + #In other contexts there may not even be another input reader + + #REVIEW - what if there is existing data in input_chunks_waiting - is it for us? + #This occurs for example with key held down on autorepeat and is normal + #enable it here for debug/testing only + #if {[info exists input_chunks_waiting($input)] && [llength $input_chunks_waiting($input)]} { + # puts stderr "[punk::ansi::a+ cyan bold]get_ansi_response_payload called while input_chunks_waiting($input) contained data: [punk::ansi::a][ansistring VIEW $input_chunks_waiting($input)]" + #} + + if {!$::punk::console::ansi_available} { + return "" + } + # -- --- + #set callid [info cmdcount] ;#info cmdcount is fast, though not as fast as clock clicks - and whilst not unique in a long-running app(will wrap?) - fine for this context + #clock clicks is approx 2x faster - but can sometimes give duplicates if called sequentially e.g list [clock clicks] [clock clicks] + #Either is suitable here, where subsequent calls will be relatively far apart in time + #speed of call insignificant compared to function + set callid [clock clicks] + # -- --- + # + + upvar ::punk::console::ansi_response_chunk accumulator + upvar ::punk::console::ansi_response_wait waitvar + upvar ::punk::console::ansi_response_queue queue + upvar ::punk::console::ansi_response_queuedata queuedata + upvar ::punk::console::ansi_response_tslaunch tslaunch + upvar ::punk::console::ansi_response_tsclock tsclock + upvar ::punk::console::ansi_response_timeoutid timeoutid + set accumulator($callid) "" + set waitvar($callid) "" + + lappend queue $callid + if {[llength $queue] > 1} { + #while {[lindex $queue 0] ne $callid} {} + set queuedata($callid) $args + set runningid [lindex $queue 0] + while {$runningid ne $callid} { + #puts stderr "." + vwait ::punk::console::ansi_response_wait + set runningid [lindex $queue 0] + if {$runningid ne $callid} { + set ::punk::console::ansi_response_wait($runningid) $::punk::console::ansi_response_wait($runningid) + update ;#REVIEW - possibly a bad idea + after 10 + set runningid [lindex $queue 0] ;#jn test + } + } + } + + + + #todo - use a linked array and an accumulatorid and waitvar id? When can there be more than one terminal query in-flight? + + set existing_handler [chan event $input readable] ;#review! + set this_handler ::punk::console::internal::ansi_response_handler_regex + if {[lindex $existing_handler 0] eq $this_handler} { + puts stderr "[punk::ansi::a+ red]Warning for callid $callid get_ansi_response_payload called while existing ansi response handler in place[a]: $this_handler" + puts stderr "queue state: $queue" + flush stderr + + if {[lindex $queue 0] ne $callid} { + error "get_ansi_response_payload - re-entrancy unrecoverable" + } + } + chan event $input readable {} + + # - stderr vs stdout + #It has to be same channel as used by functions such as test_char_width or erroneous results returned for those functions + #(presumably race conditions as to when data hits console?) + #review - experiment changing this and calling functions to stderr and see if it works + #review - Are there disadvantages to using stdout vs stderr? + + + set previous_input_state [chan configure $input] + #chan configure $input -blocking 0 + + #todo - make timeout configurable? + set waitvarname "::punk::console::ansi_response_wait($callid)" + + + #Only cycle raw mode when the input channel is actually a console/tty. + #enableRaw/disableRaw operate on the REAL process console (twapi/stty) regardless of the + #channel arg - cycling it for a query on unrelated channels (e.g pipe-pair probes from + #settle_can_respond) flips the user's console mode as a side effect, and repeated cycles + #have been observed to deadlock under stacked/captured channel environments (runtests runx). + #For mintty-without-winpty (terminal presenting pipes) the twapi raw cycle was already a + #no-op via its get_console_handle early-return, so nothing is lost by skipping. + set input_is_console_or_tty [expr {[dict exists $previous_input_state -inputmode] || [dict exists $previous_input_state -mode]}] + if {![tsv::get console is_raw]} { + set was_raw 0 + if {$input_is_console_or_tty} { + punk::console::enableRaw + #after 0 [list chan event $input readable [list $this_handler $input $callid $capturingendregex]] + incr expected 50 ;#review + } + set timeoutid($callid) [after $expected [list set $waitvarname timedout]] + #puts stdout "sending console request [ansistring VIEW $query]" + } else { + set was_raw 1 + set timeoutid($callid) [after $expected [list set $waitvarname timedout]] + } + #write before console enableRaw vs after?? + #There seem to be problems (e.g on WSL) if we write too early - the output ends up on screen but we don't read it + + switch -- $passthrough { + auto { + if {[info exists ::env(TMUX)]} { + set query "\x1bPtmux\;[string map [list \x1b \x1b\x1b] $query]\x1b\\" + } + } + tmux { + set query "\x1bPtmux\;[string map [list \x1b \x1b\x1b] $query]\x1b\\" + } + } + puts -nonewline $output $query;flush $output + chan configure $input -blocking 0 + + set tslaunch($callid) [clock millis] ;#time of launch - may be delay before first event depending on what's going on + set tsclock($callid) $tslaunch($callid) + #after 0 + #------------------ + #trying alternatives to get faster read and maintain reliability..REVIEW + #we should care more about performance in raw mode - as ultimately that's the one we prefer for full features + #------------------ + # 1) faster - races? + #first read will read 3 bytes JJJJ + $this_handler $input $callid $capturingendregex + #JJJJ + #$this_handler $input $callid $capturingendregex + if {$ignoreok || $waitvar($callid) ne "ok"} { + chan event $input readable [list $this_handler $input $callid $capturingendregex] + } + # 2) more reliable? + #chan event $input readable [list $this_handler $input $callid $capturingendregex] + #------------------ + + + #response from terminal + #e.g for cursor position \033\[46;1R + + #after 0 [list $this_handler $input $callid $capturingendregex] + set remaining $expected + if {$waitvar($callid) eq ""} { + set lastvwait [clock millis] + vwait ::punk::console::ansi_response_wait($callid) + #puts stderr ">>>> end vwait1 $waitvar($callid)<<<<" + while {[string match extend-* $waitvar($callid)] || ($ignoreok && $waitvar($callid) eq "ok")} { + if {[string match extend-* $waitvar($callid)]} { + set extension [lindex [split $waitvar($callid) -] 1] + if {$extension eq ""} { + puts "blank extension $waitvar($callid)" + puts "->[set $waitvar($callid)]<-" + } + puts stderr "get_ansi_response_payload Extending timeout by $extension for callid:$callid" + after cancel $timeoutid($callid) + set total_elapsed [expr {[clock millis] - $tslaunch($callid)}] + set last_elapsed [expr {[clock millis] - $lastvwait}] + set remaining [expr {$remaining - $last_elapsed}] + if {$remaining < 0} {set remaining 0} + set newtime [expr {$remaining + $extension}] + set timeoutid($callid) [after $newtime [list set $waitvarname timedout]] + set lastvwait [clock millis] + vwait ::punk::console::ansi_response_wait($callid) + } else { + #ignoreok - reapply the handler that disabled itself due to 'ok' + chan event $input readable [list $this_handler $input $callid $capturingendregex] + set lastvwait [clock millis] + vwait ::punk::console::ansi_response_wait($callid) + } + } + } + #response handler automatically removes it's own chan event + chan event $input readable {} ;#explicit remove anyway - review + + if {$waitvar($callid) ne "timedout"} { + after cancel $timeoutid($callid) + } else { + puts stderr "timeout (timediff [expr {[clock millis] - $tslaunch($callid)}]ms) in get_ansi_response_payload. callid $callid Ansi request was:'[ansistring VIEW -lf 1 -vt 1 $query]'" + } + + if {$was_raw == 0 && $input_is_console_or_tty} { + punk::console::disableRaw + } + + #restore $input state + #it *might* be ok to restore entire state on an input channel + #(it's not always on all channels - e.g stdout has -winsize which is read-only) + #Safest to only restore what we think we've modified. + chan configure $input -blocking [dict get $previous_input_state -blocking] + + + + set input_read [set accumulator($callid)] + + if {$input_read ne ""} { + set got_match [regexp -indices $capturingendregex $input_read _match_indices prefix_indices response_indices payload_indices] + if {$got_match} { + set responsedata [string range $input_read {*}$response_indices] + set payload [string range $input_read {*}$payload_indices] + set prefixdata [string range $input_read {*}$prefix_indices] + if {!$ignoreok && $prefixdata ne ""} { + #puts stderr "Warning - get_ansi_response_payload read extra data at start - '[ansistring VIEW -lf 1 $prefixdata]' (responsedata=[ansistring VIEW -lf 1 $responsedata])" + lappend input_chunks_waiting($input) $prefixdata + } + } else { + #timedout - or eof? + if {!$ignoreok} { + puts stderr "get_ansi_response_payload callid:$callid regex match '$capturingendregex' to input_read '[ansistring VIEW -lf 1 -vt 1 $input_read]' not found" + lappend input_chunks_waiting($input) $input_read + set payload "" + } else { + set responsedata "" + set payload "" + set prefixdata "" + } + } + } else { + #timedout or eof? and nothing read + set responsedata "" + set prefixdata "" + set payload "" + } + + # ------------------------------------------------------------------------------------- + # Other input readers + # ------------------------------------------------------------------------------------- + #is there a way to know if existing_handler is input_chunks_waiting aware? + if {[string length $existing_handler] && [lindex $existing_handler 0] ne $this_handler} { + #puts "get_ansi_response_payload reinstalling ------>$existing_handler<------" + chan event $input readable $existing_handler + #this_handler may have consumed all pending input on $input - so there may be no trigger for the readable chan event for existing_handler + if {[llength $input_chunks_waiting($input)]} { + #This is experimental If a handler is aware of input_chunks_waiting - there should be no need to schedule a trigger + #If it isn't, but the handler can accept an existing chunk of data as a 'waiting' argument - we could trigger and pass it the waiting chunks - but there's no way to know its API. + #we could look at info args - but that's not likely to tell us much in a robust way. + #we could create a reflected channel for stdin? That is potentially an overreach..? + #triggering it manually... as it was already listening - this should generally do no harm as it was the active reader anyway, but won't help with the missing data if it's input_chunks_waiting-unaware. + set handler_args [info args [lindex $existing_handler 0]] + if {[lindex $handler_args end] eq "waiting"} { + #Looks like the existing handler is setup for punk repl cooperation. + + puts stdout "\n\n[punk::ansi::a+ yellow bold]-->punk::console::get_ansi_response_payload callid $callid triggering existing handler\n $existing_handler while over-read data is in punk::console::input_chunks_waiting($input) instead of channel[punk::ansi::a]" + puts stdout "[punk::ansi::a+ yellow bold]-->waiting: [ansistring VIEW -lf 1 -vt 1 $input_chunks_waiting($input)][punk::ansi::a]" + flush stdout + + #concat and supply to existing handler in single text block - review + #Note will only + set waitingdata [join $input_chunks_waiting($input) ""] + set input_chunks_waiting($input) [list] + #after idle [list after 0 [list {*}$existing_handler $waitingdata]] + after idle [list {*}$existing_handler $waitingdata] ;#after 0 may be put ahead of events it shouldn't be - review + unset waitingdata + } else { + #! todo? for now, emit a clue as to what's happening. + puts stderr "[punk::ansi::a+ yellow bold]-->punk::console::get_ansi_response_payload cannot trigger existing handler $existing_handler while over-read data is in punk::console::input_chunks_waiting($input) instead of channel [ansistring VIEW $input_chunks_waiting($input)][punk::ansi::a]" + #If $input is at eof here, we deliberately take no action - a failed query returns + #empty/timeout to the caller and the active reader will discover the eof through its + #normal read path (repl::start ends with done='eof ' and the application layer + #e.g app-punkshell PUNK_PIPE_EOF policy decides between exit and console reopen). + #Restarting the repl from inside this low-level query primitive caused re-entrancy + #and bypassed that policy. + } + } + #Note - we still may be in_repl_handler here (which disables its own reader while executing commandlines) + #The input_chunks_waiting may really belong to the existing_handler we found - but if it doesn't consume them they will end up being read by the repl_handler when it eventually re-enables. + #todo - some better structure than just a list of waiting chunks indexed by input channel, so repl/other handlers can determine the context in which these waiting chunks were generated? + } elseif {[package provide punk::repl::codethread] ne "" && [punk::repl::codethread::is_running]} { + #if {[llength $input_chunks_waiting($input)]} { + #don't trigger the repl handler manually - we will inevitably get things out of order - as it knows when to enable/disable itself based on whether chunks are waiting. + #triggering it by putting it on the eventloop will potentially result in re-entrancy + #The cooperating reader must be designed to consume waiting chunks and only reschedule it's channel read handler once all waiting chunks have been consumed. + #puts stderr "[punk::ansi::a+ green bold]--> repl_handler has chunks to consume [ansistring VIEW $input_chunks_waiting($input)][punk::ansi::a]" + #} + #If $input is at eof here, we deliberately take no action (previously an erroneous repl + #restart was attempted from this branch). A failed query returns empty/timeout to the caller; + #the repl reader discovers the eof through its normal read path and the application layer + #(e.g app-punkshell PUNK_PIPE_EOF policy) owns the exit-vs-console-reopen decision. + } + # ------------------------------------------------------------------------------------- + + + + unset -nocomplain accumulator($callid) + unset -nocomplain waitvar($callid) + unset -nocomplain timeoutid($callid) + unset -nocomplain tsclock($callid) + unset -nocomplain tslaunch($callid) + dict unset queuedata $callid + + #lpop queue 0 + ledit queue 0 0 + if {[llength $queue] > 0} { + set next_callid [lindex $queue 0] + set waitvar($callid) go_ahead + #set nextdata [set queuedata($next_callid)] + } + + + + #set punk::console::chunk "" + if {$returntype eq "dict"} { + return [dict create {*}{ + } prefix $prefixdata {*}{ + } payload $payload {*}{ + } response $responsedata {*}{ + } all $input_read {*}{ + } + ] + } else { + return $payload + } + } + + + #review - reading 1 byte at a time and repeatedly running the small capturing/completion regex seems a little inefficient... but we don't have a way to peek or put back chars (?) + #review (we do have the punk::console::input_chunks_waiting($chan) array to cooperatively put back data - but this won't work for user scripts not aware of this) + #review - timeout - what if terminal doesn't put data on stdin? error vs stderr report vs empty results + #review - Main loop may need to detect some terminal responses and store them for lookup instead-of or as-well-as this handler? + #e.g what happens to mouse-events while user code is executing? + #we may still need this handler if such a loop doesn't exist. + proc ansi_response_handler_regex {chan callid endregex} { + upvar ::punk::console::ansi_response_chunk chunks + upvar ::punk::console::ansi_response_wait waits + upvar ::punk::console::ansi_response_tslaunch tslaunch ;#initial time in millis was set when chan event was created + upvar ::punk::console::ansi_response_tsclock tsclock + + #endregex should explicitly have a trailing $ + if {[string length $chunks($callid)] == 0} { + set status [catch {read $chan 3} bytes] + } else { + set status [catch {read $chan 1} bytes] + } + if { $status != 0 } { + # Error on the channel + chan event $chan readable {} + puts "ansi_response_handler_regex error reading $chan: $bytes" + set waits($callid) [list error error_read status $status bytes $bytes] + } elseif {$bytes ne ""} { + #puts stderr . ;flush stderr + # Successfully read the channel + #puts "got: [string length $bytes]bytes" + set sofar [append chunks($callid) $bytes] + #puts stderr [ansistring VIEW $chunks($callid)] + #review - what is min length of any ansiresponse? + #we know there is at least one of only 3 chars, vt52 response to ESC Z: ESC / Z + #endregex is capturing - but as we are only testing the match here + #it should perform the same as if it were non-capturing + if {[string length $sofar] > 2 && [regexp $endregex $sofar]} { + #puts stderr "matched - setting ansi_response_wait($callid) ok" + chan event $chan readable {} + set waits($callid) ok + } else { + # 30ms 16ms? + set tsnow [clock millis] + set total_elapsed [expr {[set tslaunch($callid)] - $tsnow}] + set last_elapsed [expr {[set tsclock($callid)] - $tsnow}] + if {[string length $sofar] % 10 == 0 || $last_elapsed > 16} { + if {$total_elapsed > 3000} { + #REVIEW + #too long since initial read handler launched.. + #is other data being pumped into stdin? Eventloop starvation? Did we miss our codes? + #For now we'll stop extending the timeout. + after cancel $::punk::console::ansi_response_timeoutid($callid) + set waits($callid) [list error error_ansi_response_handler_regex_too_long_reading] + } else { + if {$last_elapsed > 0} { + after cancel $::punk::console::ansi_response_timeoutid($callid) + set waits($callid) extend-[expr {min(16,$last_elapsed)}] + } + } + } + set tsclock(callid) [clock millis] + } + } elseif {[catch {eof $chan}] || [eof $chan]} { + catch {chan event $chan readable {}} + # End of file on the channel + #review + puts stderr "ansi_response_handler_regex end of file on channel $chan" + set waits($callid) eof + } elseif {![catch {chan blocked $chan}] && [chan blocked $chan]} { + # Read blocked is normal. (chan -blocking = 0 but reading only 1 char) + # Caller should be using timeout on the wait variable + #set waits($callid) continue + set tsclock($callid) [clock millis] + } else { + chan event $chan readable {} + # Something else + puts stderr "ansi_response_handler_regex Situation shouldn't be possible. No error and no bytes read on channel $chan but not chan blocked or EOF" + set waits($callid) error_unknown_zerobytes_while_not_blocked_or_eof + } + } + } ;#end namespace eval internal + + variable colour_disabled 0 + #todo - move to punk::config + # https://no-color.org + if {[info exists ::env(NO_COLOR)]} { + if {$::env(NO_COLOR) ne ""} { + set colour_disabled 1 + } + } + + #a and a+ functions are not very useful when emitting directly to console + #e.g puts [punk::console::a red]test[punk::console::a cyan] would produce a cyan coloured test as the commands are evaluated first + + + #punk::args::set_idalias ::punk::console::code_a+ ::punk::ansi::a+ + lappend PUNKARGS_aliases {::punk::console::code_a+ ::punk::ansi::a+} + proc code_a+ {args} { + variable ansi_wanted + if {$ansi_wanted <= 0} { + return + } + #a and a+ are called a *lot* - avoid even slight overhead of tailcall as it doesn't give us anything useful here + #tailcall punk::ansi::a+ {*}$args + ::punk::ansi::a+ {*}$args + } + lappend PUNKARGS_aliases {::punk::console::code_a ::punk::ansi::a} + proc code_a {args} { + variable ansi_wanted + if {$ansi_wanted <= 0} { + return + } + #tailcall punk::ansi::a {*}$args + variable is_vt52 + if {$is_vt52} { + return + } else { + ::punk::ansi::a {*}$args + } + } + lappend PUNKARGS_aliases {::punk::console::code_a? ::punk::ansi::a?} + proc code_a? {args} { + variable ansi_wanted + if {$ansi_wanted <= 0} { + return [punk::ansi::ansistripraw [::punk::ansi::a? {*}$args]] + } else { + tailcall ::punk::ansi::a? {*}$args + } + } + + #REVIEW! this needs reworking. + #It needs to be clarified as to what ansi off is supposed to do. + #Turning ansi off only stops new ansi being generated - but what about codes stored in configurations of existing elements such as tables/frames? + #It will stop underlines/bold/reverse as well as SGR colours + #colour off is more specific - it only stops SGR colour codes from being generated - but underlines/bold/reverse will still be generated. + #what about ansi movement codes etc? + #we already have colour on|off which disables SGR codes in a+ etc as well as stderr/stdout channel transforms + proc ansi {{onoff {}}} { + variable ansi_wanted + if {[string length $onoff]} { + if {$onoff eq "default"} { + set ansi_wanted 2 + catch {punk::repl::reset_prompt} + return + } + if {![string is boolean -strict $onoff]} { + error "punk::console::ansi expected a boolean e.g 0|1|on|off|true|false|yes|no" + } + if {$onoff} { + set ansi_wanted 1 + } else { + set ansi_wanted 0 + punk::ansi::sgr_cache -action clear + } + catch {punk::repl::reset_prompt} + } + puts stderr "::punk::console::ansi - use 'colour' command to turn SGR codes on/off (except bold/underline/reverse which are controlled by 'ansi' command)" + return [expr {$ansi_wanted}] + } + + #colour + # Turning colour off will stop SGR colour codes from being generated unless 'forcecolour' is added to the argument list for the punk::ans::a functions + proc colour {{on {}}} { + variable colour_disabled + if {$on ne ""} { + if {![string is boolean -strict $on]} { + error "punk::console::colour expected a boolean e.g 0|1|on|off|true|false|yes|no" + } + #an experiment with complete disabling vs test of state for each call + if {$on} { + if {$colour_disabled} { + #change of state + punk::ansi::sgr_cache -action clear + catch {punk::repl::reset_prompt} + set colour_disabled 0 + } + } else { + #we don't disable a/a+ entirely - they must still emit underlines/bold/reverse + if {!$colour_disabled} { + #change of state + punk::ansi::sgr_cache -action clear + catch {punk::repl::reset_prompt} + set colour_disabled 1 + } + } + } + return [expr {!$colour_disabled}] + } + + #test - find a better place to set terminal type + variable is_vt52 0 + proc vt52 {{onoff {}}} { + #todo - return to colour state beforehand?. support 0-15 vt52 colours? (ATARI and some emulators - not original vt52) + #we shouldn't have to turn off colour to enter vt52 - we should make punk::console emit correct codes + variable is_vt52 + if {$onoff eq ""} { + return $is_vt52 + } + if {![string is boolean -strict $onoff]} { + error "vt52 setting must be a boolean - or empty to query" + } + if {$is_vt52} { + if {!$onoff} { + puts -nonewline "\x1b<" + set is_vt52 0 + colour on + } + } else { + if {$onoff} { + dec_unset_mode DECANM + set is_vt52 1 + colour off + } else { + puts -nonewline "\x1b<" + #emit even though our is_vt52 flag thinks it's on. Should be harmless if underlying terminal already vt100+ + } + } + return $is_vt52 + } + + namespace eval local { + proc set_codepage_output {cpname} { + #todo + if {"windows" eq $::tcl_platform(platform)} { + twapi::set_console_output_codepage $cpname + } else { + error "set_codepage_output unimplemented on $::tcl_platform(platform)" + } + } + proc set_codepage_input {cpname} { + #todo + if {"windows" eq $::tcl_platform(platform)} { + twapi::set_console_input_codepage $cpname + } else { + error "set_codepage_input unimplemented on $::tcl_platform(platform)" + } + } + lappend PUNKARGS [list { + @id -id ::punk::console::local::echo + @cmd -name punk::console::local::echo -help\ + "Use stty on unix, or twapi on windows to set terminal + local input echo on/off - experimental" + @values -min 0 -max 1 + onoff -type boolean -default "" -help\ + "Omit or pass empty string to query current echo state." + }] + proc echo {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::local::echo] + set onoff [dict get $argd values onoff] + + set is_windows [string equal "windows" $::tcl_platform(platform)] + if {$onoff eq ""} { + #query + if {$is_windows} { + package require twapi + set inputstate [twapi::get_console_input_mode] + return [dict get $inputstate -echoinput] + } else { + #counterintuitively - the human format (-a) seems more consistent across platforms than the machine readable (-g) formats + #for now, quick and dirty look for echo in the list seems to work on wsl & freebsd at least. + set tstate [exec stty -a] + if {[lsearch $tstate echo] > 0} { + return 1 + } else { + return 0 + } + } + } else { + if {![string is boolean -strict $onoff]} { + error "::punk::console::local::echo requires boolean argument to set on or off" + } + if {$is_windows} { + set onoff [expr {true && $onoff}] ;#ensure true,yes etc are converted to 1|0 + set conh [twapi::get_console_handle stdin] + twapi::modify_console_input_mode $conh -echoinput $onoff + + return $onoff + } else { + if {$onoff} { + {*}[auto_execok stty] echo + return 1 + } else { + {*}[auto_execok stty] -echo + return 0 + } + } + } + } + } + namespace import local::set_codepage_output + namespace import local::set_codepage_input + + + lappend PUNKARGS [list { + @id -id ::punk::console::show_input_response + @cmd -name punk::console::show_input_response -help\ + "Debug command for console queries using ANSI" + @opts + -console -default {stdin stdout} -type list -help\ + "console/terminal (currently list of in/out channels) (todo - object?)" + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + -expected_ms -type integer -default 500 -help\ + "Number of ms to wait for response" + + # -mincap 3 -maxcap 3 validates that regex has exactly 3 capturing groups. + -capture -type regex -default {(((.*)))$} -mincap 3 -maxcap 3 -help\ + "Regex to capture response with. + - must have 3 capturing groups to capture prefix, payload and entire response. + The regex is applied repeatedly to the input until a match is found or timeout occurs. + + The default regex captures the entire response without differentiating prefix or payload + - this is useful for debugging and for queries where we don't know what the response will look like. + " + @values -min 1 -max 1 + request -type string -help\ + {ANSI sequence such as \x1b\[?6n which + should elicit a response by the terminal + on stdin} + }] + proc show_input_response {args} { + set argd [punk::args::parse $args withid ::punk::console::show_input_response] + lassign [dict values $argd] leaders opts values received + set request [dict get $values request] + set inoutchannels [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set expected [dict get $opts -expected_ms] + #set capturingregex {(((.*)))$} ;#capture entire response same as response-payload + set capturingregex [dict get $opts -capture] + + set ts_start [clock millis] + set response [punk::console::internal::get_ansi_response_payload -ignoreok 1 -return dict -expected_ms $expected -console $inoutchannels -passthrough $passthrough $request $capturingregex] + set ts_end [clock millis] + + set outchan [lindex $inoutchannels 1] + set out "" + + #for the default capturing regexp; prefix,payload,response and all will be the same because our capturingregex is not specific to the query. + dict for {k v} $response { + append out "$k [ansistring VIEW $v]" \n + } + append out "totalms [expr {$ts_end - $ts_start}]" + return $out + } + + # -- --- --- --- --- --- --- + #get_ansi_response functions + #review - can these functions sensibly be used on channels not attached to the local console? + #ie can we default to {stdin stdout} but allow other channel pairs? + # -- --- --- --- --- --- --- + proc get_cursor_pos {{inoutchannels {stdin stdout}}} { + if {$::punk::console::is_vt52} { + error "vt52 can't perform get_cursor_pos" + } + #response from terminal + #e.g \033\[46;1R + set capturingregex {(.*)(\x1b\[([0-9]+;[0-9]+)R)$} ;#must capture prefix,entire-response,response-payload + + #This is all 'tput u7' does (emits CSI 6n on stdout) + set request "\033\[6n" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + #some terminals fail to respond properly to \x1b\[6n but do respond to \x1b\[?6n and vice-versa :/ + #todo - what? + #often terminals that fail will just put the raw request code on stdin - we could detect that and then + #try the other? + + return $payload + } + + + + proc get_checksum_rect {id page t l b r {inoutchannels {stdin stdout}}} { + #e.g \x1b\[P44!~E797\x1b\\ + #re e.g {(.*)(\x1b\[P44!~([[:alnum:]])\x1b\[\\)$} + set capturingregex [string map [list %id% $id] {(.*)(\x1bP%id%!~([[:alnum:]]+)\x1b\\)$}] + set request "\x1b\[${id}\;${page}\;$t\;$l\;$b\;$r*y" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + proc get_device_status {{inoutchannels {stdin stdout}}} { + set capturingregex {(.*)(\x1b\[([0-9]+)n)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[5n" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + + variable last_da1_result "" + #TODO - 22? 28? 32? + #1 132 columns + #2 Printer port extension + #4 Sixel extension + #6 Selective erase + #7 DRCS + #8 UDK + #9 NRCS + #12 SCS extension + #15 Technical character set + #18 Windowing capability + #21 Horizontal scrolling + #23 Greek extension + #24 Turkish extension + #42 ISO Latin 2 character set + #44 PCTerm + #45 Soft key map + #46 ASCII emulation + + #https://vt100.net/docs/vt510-rm/DA1.html + # + proc get_device_attributes {{inoutchannels {stdin stdout}}} { + #Note the vt52 rough equivalen \x1bZ - commonly supported but probably best considered obsolete as it collides with ECMA 48 SCI Single Character Introducer + #DA1 + variable last_da1_result + #first element in result is the terminal's architectural class 61,62,63,64.. ? + #for vt100 we get things like: "ESC\[?1;0c" + #for vt102 "ESC\[?6c" + + #set capturingregex {(.*)(\x1b\[\?6[0-9][;]*([;0-9]+)c)$} ;#must capture prefix,entire-response,response-payload + set capturingregex {(.*)(\x1b\[\?([0-9]*[;0-9]+)c)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[c" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + set last_da1_result $payload + return $payload + } + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::get_device_attributes_secondary + @cmd -name punk::console::get_device_attributes_secondary\ + -summary\ + {Query terminal for secondary device attributes}\ + -help\ + {Query terminal secondary device attributes by sending to the console + the sequence: + ESC [ > c + + The terminal should respond with a sequence like: + ESC [ > 61;1;0c + Where the + first parameter is the terminal's architectural class, and the + second parameter is often a version indicator, and the + third parameters is 0 or 1 to indicate (indicates std keyboard vs pc keyboard) + The keyboard paramter is largely obsolete and most modern virtual terminals just report 0 here + - but physical terminals may use it. + + This is not cached, as a terminal's device emulation mode may change during a session, and some + terminals may reflect that in their reported secondary device attributes. + + See also: + - punk::console::classify_device_attributes_secondary + - punk::console::class_info + + - punk::console::get_device_attributes + - punk::console::get_device_attributes_tertiary + - punk::console::ansi_modes + - punk::console::dec_modes + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 0 -max 0 + }] + } + #see : https://vt100.net/docs/vt510-rm/DA2.html + #see also: https://github.com/mintty/utils/blob/master/terminal + proc get_device_attributes_secondary {args} { + #parse without punk::args::parse for performance and so it can be used prior to punk::args being available + #(sometimes gets called before this package has fully loaded and registered its argspecs) + #---------------------------------------- + set defaults [dict create {*}{ + -console {stdin stdout} + }] + set opts [dict merge $defaults $args] + for {set i 0} {$i < [llength $args]} {incr i} { + set a [lindex $args $i] + if {[string match -* $a]} { + set fullopt [tcl::prefix::match -error "" [dict keys $defaults] $a] + if {[dict exists $defaults $fullopt]} { + set value [lindex $args [expr {$i + 1}]] + dict set opts $fullopt $value + incr i + } else { + error "Unknown option '$a'" + } + } else { + error "Unexpected argument '$a'" + } + } + #---------------------------------------- + set inoutchannels [dict get $opts -console] + #DA2 + set capturingregex {(.*)(\x1b\[\>([0-9]*[;][0-9]*[;][0-1])c)$} ;#must capture prefix,entire-response,response-payload + #expect eg CSI > X;10|20;0|1c - docs suggest X should be something like 61. In practice we see 0 or 1 (windows) REVIEW + set request "\x1b\[>c" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::classify_device_attributes_secondary + @cmd -name punk::console::classify_device_attributes_secondary\ + -summary\ + {Parse and classify the response payload from get_device_attributes_secondary into a dict}\ + -help\ + {Parse and classify the response payload from get_device_attributes_secondary into a dict with keys: + - class + - version + - keyboard + - notes + - raw + + The class is a human friendly indicator of the terminal's architectural class + e.g vt100, vt240,vt510 etc + The version is often a software version indicator for modern virtual terminals + but may be a firmware version for physical terminals. + The keyboard value will be std (0) for most modern virtual terminals, but may be pc (1) for some physical terminals. + The notes field may contain any additional information or context about the terminal's capabilities or quirks that + can be inferred from the class, version, and keyboard values. + The raw field is a list of the raw values for class, version and keyboard respectively. + + If a friendly name is not known for any of the values, the string "unknown" is used instead of the value. + For some known combinations of class and version, the class may be overridden with a more specific name, + and notes may be added to provide additional context. + + The response from get_device_attributes_secondary is expected to be in the format: + class;version;keyboard + Where: + - class is the terminal's architectural class (e.g. 61, 62, 63, 64) + - version is often a version indicator (e.g. 1, 2, 10, 20) + - keyboard is 0 or 1 to indicate (indicates std keyboard vs pc keyboard) + The keyboard parameter is largely obsolete and most modern virtual terminals just report 0 here, but physical terminals may use it. + + see also: + - punk::console::get_device_attributes_secondary + - punk::console::class_info + } + @values -min 1 -max 1 + attributestring -type string -help\ + {The response payload from get_device_attributes_secondary to parse and classify, + expected in the format class;version;keyboard e.g 61;1;0 + (will also accept raw response with leading ESC[> and trailing c if present, but these are not required)} + }] + } + proc classify_device_attributes_secondary {attributestring} { + #regexp vs string match - both need to escape square brackets. + # string match "\x1b\\\[>*c" + # regexp {^\x1b\[>.*c$} + if {[string match "\x1b\\\[>*c" $attributestring]} { + #strip leading ESC[> and trailing c if present + set attributestring [string range $attributestring 3 end-1] + } + set parts [split $attributestring ";"] + if {[llength $parts] != 3} { + error "Unexpected response format - expected 3 semicolon-separated values for class;version;keyboard. got: '$attributestring'" + } + set class [lindex $parts 0] + set version [lindex $parts 1] + set keyboard [lindex $parts 2] + set notes "" + + + #info for class_name is based on the way mintty operates. + switch -- $class { + 0 { + set class_name "VT100" + switch -- $version { + 10 { + set class_name "winconsole" + #we don't report 'windows' as this is confusing given that the version is reported as 10 - but this has nothing to do with the windows os version. + set notes "windows reporting vt100 (other terminals such as putty connected to windows openssh (windows-pty) sshd, or mintty using winpty may also report this)" + } + 95 { + set class_name "tmux" + set notes "tmux reporting VT100" + } + 115 { + set class_name "KDE-konsole" + set notes "KDE-konsole reporting VT100" + } + 136 { + set class_name "PuTTY" + set notes "PuTTY reporting VT100" + } + 304 { + set class_name "VT125" + } + } + + + } + 1 { + set class_name "VT200" + switch -- $version { + 2 { + set class_name "openwin-xterm" + set notes "openwin xterm reporting vt200" + } + 96 { + set class_name "mlterm" + set notes "mlterm reporting vt200" + } + 304 { + set class_name "VT241/VT382" + } + 1115 { + set class_name "gnome-terminal" + set notes "gnome-terminal reporting vt200" + } + default { + if {$version > 2000} { + set class_name "gnome-terminal" + set notes "gnome-terminal reporting vt200 (version > 2000)" + } + } + } + } + 2 {set class_name "VT240"} + 24 {set class_name "VT320"} + 28 {set class_name "DECterm"} + 18 {set class_name "VT330"} + 19 {set class_name "VT340"} + 41 {set class_name "VT420"} + 61 {set class_name "VT510"} + 64 {set class_name "VT520"} + 65 {set class_name "VT525"} + 67 {set class_name "cygwin"} + 77 {set class_name "mintty"} + 82 {set class_name "rxvt"} + 83 {set class_name "screen"} + 84 { + set class_name "tmux" + set version 200 + } + 85 {set class_name "rxvt-unicode"} + + default {set class_name "unknown"} + } + + #return version as-is. + + #map keyboard value to friendly name + switch -- $keyboard { + 0 {set keyboard_name "std"} + 1 {set keyboard_name "pc"} + default {set keyboard_name "unknown"} + } + + return [dict create {*}{ + } class $class_name {*}{ + } version $version {*}{ + } keyboard $keyboard_name {*}{ + } notes $notes {*}{ + } raw $parts {*}{ + } + ] + } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::class_info + @cmd -name punk::console::class_info\ + -summary\ + {Query terminal secondary device attributes, parse and classify}\ + -help\ + {Query terminal secondary device attributes by sending to the console + the sequence: + ESC [ > c + and then pass the payload through classify_device_attributes_secondary to + return a dict of information about the terminal. + + The results are parsed into a dict with keys: + - class + - version + - keyboard + - notes + - raw + + See also: + - punk::console::get_device_attributes_secondary + - punk::console::classify_device_attributes_secondary + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 0 -max 0 + }] + } + proc class_info {args} { + #set argd [punk::args::parse $args withid ::punk::console::class_info] + #lassign [dict values $argd] leaders opts values received + #---------------------------------------- + set defaults [dict create {*}{ + -console {stdin stdout} + }] + set opts [dict merge $defaults $args] + for {set i 0} {$i < [llength $args]} {incr i} { + set a [lindex $args $i] + if {[string match -* $a]} { + set fullopt [tcl::prefix::match -error "" [dict keys $defaults] $a] + if {[dict exists $defaults $fullopt]} { + set value [lindex $args [expr {$i + 1}]] + dict set opts $fullopt $value + incr i + } else { + error "Unknown option '$a'" + } + } else { + error "Unexpected argument '$a'" + } + } + #---------------------------------------- + set inoutchannels [dict get $opts -console] + + set da2_response [get_device_attributes_secondary -console $inoutchannels] + set parsed [classify_device_attributes_secondary $da2_response] + return $parsed + } + proc get_device_attributes_tertiary {{inoutchannels {stdin stdout}}} { + #DA3 + set capturingregex {(.*)(\x1bP!\|([0-9]{8})\x1b\\)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[=c" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + proc get_terminal_id {{inoutchannels {stdin stdout}}} { + #DA3 - alias + get_device_attributes_tertiary $inoutchannels + } + + proc get_tabstops {{inoutchannels {stdin stdout}}} { + #DECTABSR \x1b\[2\$w + #response example " ^[P2$u9/17/25/33/41/49/57/65/73/81^[\ " (where ^[ is \x1b) + #set capturingregex {(.*)(\x1b\[P2$u()\x1b\[\\)} + #set capturingregex {(.*)(\x1bP2$u((?:[0-9]+)*(?:\/[0-9]+)*)\x1b\\)$} + set capturingregex {(.*)(\x1bP2\$u(.*)\x1b\\)$} + set request "\x1b\[2\$w" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + set tabstops [split $payload "/"] + return $tabstops + } + + #a simple estimation of tab-width under assumption console is set with even spacing. + #It's known this isn't always the case - but things like textutil::untabify2 take only a single value + #on some systems test_char_width is a similar speed to get_tabstop_apparent_width - but on some test_char_width is much slower + #we will use test_char_width as a fallback + proc get_tabstop_apparent_width {{inoutchannels {stdin stdout}}} { + set tslist [get_tabstops $inoutchannels] + if {![llength $tslist]} { + #either terminal failed to report - or none set. + set testw [test_char_width \t] + if {[string is integer -strict $testw]} { + return $testw + } + #We don't support none - default to 8 + return 8 + } + #we generally expect to see a tabstop at column 1 - but it may not be set. + if {[lindex $tslist 0] eq "1"} { + if {[llength $tslist] == 1} { + set testw [test_char_width \t] + if {[string is integer -strict $testw]} { + return $testw + } + return 8 + } else { + set next [lindex $tslist 1] + return [expr {$next - 1}] + } + } else { + #simplistic guess at width - review - do we need to consider leftmost tabstops as more likely to be non-representative and look further into the list? + if {[llength $tslist] == 1} { + return [lindex $tslist 0] + } else { + return [expr {[lindex $tslist 1] - [lindex $tslist 0]}] + } + } + } + #default to 8 just because it seems to be most common default in terminals + proc set_tabstop_width {{w 8}} { + set tsize [get_size] + set width [dict get $tsize columns] + set mod [expr {$width % $w}] + set max [expr {$width - $mod}] + set tstop_ansi "" + set c 1 + while {$c <= $max} { + append tstop_ansi [string repeat " " $w][punk::ansi::set_tabstop] + incr c $w + } + set ::punk::console::tabwidth $w ;#we also attempt to read terminal's tabstops and set tabwidth to the apparent spacing of first non-1 value in tabstops list. + #catch {textutil::tabify::untabify2 "" $w} ;#textutil tabify can end up uninitialised and raise errors like "can't read Spaces().." after a tabstop change This call seems to keep tabify happy - review. + puts -nonewline "[punk::ansi::clear_all_tabstops]\n[punk::ansi::set_tabstop]$tstop_ansi" + } + + + proc get_cursor_pos_list {{inoutchannels {stdin stdout}}} { + return [split [get_cursor_pos $inoutchannels] ";"] + } + + #todo - work out how to query terminal and set cell size in pixels + #for now use the windows default + variable cell_size + set cell_size "" + set cell_size_fallback 10x20 + + #todo - change -console to -terminalobject with prebuilt default + + punk::args::define { + @id -id ::punk::console::cell_size + -console -default {stdin stdout} -type list + @values -min 0 -max 1 + newsize -default "" -help\ + "character cell pixel dimensions WxH + or omit to query cell size." + } + proc cell_size {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::cell_size] + set terminal [dict get $argd opts -console] + set newsize [dict get $argd values newsize] + + variable cell_size + if {$newsize eq ""} { + #query existing setting + if {$cell_size eq ""} { + #not set - try to query terminal's overall dimensions + set pixeldict [punk::console::get_xterm_pixels $terminal] + lassign $pixeldict _w sw _h sh + if {[string is integer -strict $sw] && [string is integer -strict $sh]} { + lassign [punk::console::get_size] _cols columns _rows rows + #review - is returned size in pixels always a multiple of rows and cols? + set w [expr {$sw / $columns}] + set h [expr {$sh / $rows}] + set cell_size ${w}x${h} + return $cell_size + } else { + set cell_size $::punk::console::cell_size_fallback + puts stderr "punk::console::cell_size unable to query terminal for pixel data - using default $cell_size" + return $cell_size + } + } + return $cell_size + } + #newsize supplied - try to set + lassign [split [string tolower $newsize] x] w h + if {![string is integer -strict $w] || ![string is integer -strict $h] || $w < 1 || $h < 1} { + error "punk::sixel::cell_size error - expected format WxH where W and H are positive integers - got '$newsize'" + } + set cell_size ${w}x${h} + } + punk::args::define { + @id -id ::punk::console::test_is_vt52 + @cmd -name punk::console::test_is_vt52 -help\ + "in development.. broken" + -inoutchannels -default {stdin stdout} -type list + @values -min 0 -max 0 + } + + #only works in raw mode for windows terminal - (esc in output stripped?) why? + # works in line mode for alacrity and wezterm + proc test_is_vt52 {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::test_is_vt52] + set inoutchannels [dict get $argd opts -inoutchannels] + #ESC / K VT52 without printer + #ESC / M VT52 with printer + #ESC / Z VT52 emulator?? review + + #TODO + set capturingregex {(.*)(?:(\x1b\/(Z))|(\x1b\/(K))|(\x1b\/(M))|(\x1b\[\?([0-9;]+)c))$} ;#must capture prefix,entire-response,response-payload + #set capturingregex {(.*)(\x1b\[([0-9;]+)c)$} ;#must capture prefix,entire-response,response-payload + set request "\x1bZ" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + #puts -->$payload<-- + return [expr {$payload in {Z K M}}] + } + + #todo - determine cursor on/off state before the call to restore properly. + variable get_size_mechanism + set get_size_mechanism [dict create] + + #Determine with certainty whether an input channel is closed or at eof (and so can never deliver + #a terminal response). A pipe that has been fully consumed/half-closed may not have its eof flag set + #until a read is attempted - so for pipe-like channels a non-blocking 1-byte probe read is performed. + #Any byte consumed by the probe is preserved in ::punk::console::input_chunks_waiting($input) for + #cooperating readers (repl etc). + proc input_at_eof {{input stdin}} { + if {[catch {chan eof $input} is_eof]} { + return 1 ;#closed/invalid channel - unusable + } + if {$is_eof} { + return 1 + } + if {[catch {chan configure $input} conf]} { + return 1 + } + if {[dict exists $conf -inputmode] || [dict exists $conf -mode]} { + #console/tty - not subject to the deferred-eof pipe situation, and we must not consume a + #pending keystroke with a probe read here. + return 0 + } + #pipe-like channel - probe without blocking to force eof detection + if {[catch { + set prior_blocking [dict get $conf -blocking] + chan configure $input -blocking 0 + set probe [read $input 1] + chan configure $input -blocking $prior_blocking + }]} { + return 1 + } + if {$probe ne ""} { + upvar ::punk::console::input_chunks_waiting input_chunks_waiting + lappend input_chunks_waiting($input) $probe + } + return [chan eof $input] + } + + #Best effort determination of whether an input channel could be connected to a terminal able to + #answer ANSI queries (cursor position reports etc). + #Returns 0 only when reasonably confident it cannot: channel closed/at eof (probed), or no console/tty + #indicators and no terminal-suggesting environment hints. + #Note: mintty without winpty (e.g git bash) presents a terminal's stdin as a pipe with no -inputmode, + #so absence of console/tty indicators alone must not be treated as 'not a terminal'. Environment hints + #(MSYSTEM, TERM_PROGRAM) are used to avoid false negatives there, at the cost of false positives in + #environments that inherit such vars with genuinely piped (but still open) input - those paths still + #rely on the ANSI query timeout mechanisms rather than hanging indefinitely. + proc is_input_console_or_tty {{input stdin}} { + if {[catch {chan configure $input} conf]} { + return 0 + } + if {[dict exists $conf -inputmode]} { + #windows console (and some other terminal environments) + return 1 + } + if {[dict exists $conf -mode]} { + #tty on unix-like platforms + return 1 + } + if {[input_at_eof $input]} { + return 0 + } + if {[info exists ::env(TERM_PROGRAM)] || [info exists ::env(MSYSTEM)]} { + #possibly mintty (or similar) without winpty - terminal presenting channels as pipes + return 1 + } + return 0 + } + + #Resolve a console specification to its channel pair and (optionally) governing object. + #Accepted forms: + # 2-element list - {in out} channel pair (legacy/primitive form) + # 1-element value - name of an anchored opunk::console instance + # >=7 element list - an ::opunk::Console (or subclass) object value (voo) + #Returns dict: in out object + #The opunk::console package is only required for the name/object forms (lazy - punk::console + #itself must remain loadable without opunk::console; voo is now in bootsupport via + #include_modules.config but opunk::console is not, and channel-pair-only callers should not + #pay any object-layer load cost). + proc console_spec_resolve {spec} { + switch -exact -- [llength $spec] { + 2 { + set in [lindex $spec 0] + set out [lindex $spec 1] + #auto-attach the process-default console object (anchored opunk::console instance + #'default' - normally constructed during repl::init) when it governs this channel pair, + #so all query sites benefit from its settled facts without explicitly passing it. + #info exists on the anchor variable is cheap and requires no package load. + if {[info exists ::opunk::console::instances::default]} { + set dobj [set ::opunk::console::instances::default] + if {[::opunk::Console::in $dobj] eq $in && [::opunk::Console::out $dobj] eq $out} { + return [dict create in $in out $out object $dobj] + } + } + return [dict create in $in out $out object ""] + } + 1 { + if {[catch {package require opunk::console} errM]} { + error "console_spec_resolve: spec '$spec' looks like an anchored console instance name but package opunk::console is unavailable ($errM)" + } + set obj [opunk::console::instance $spec] + return [dict create in [::opunk::Console::in $obj] out [::opunk::Console::out $obj] object $obj] + } + default { + if {[llength $spec] >= 7} { + set tag [lindex $spec 0] + if {![catch {package require opunk::console}] && ![catch {voo::isVooClass $tag} isclass] && $isclass} { + return [dict create in [::opunk::Console::in $spec] out [::opunk::Console::out $spec] object $spec] + } + } + error "console_spec_resolve: unrecognised console specification '$spec' - expected {in out} channel pair, anchored opunk::console instance name, or ::opunk::Console object value" + } + } + } + + #Return the name of the anchored process-default console instance (creating it for {stdin stdout} + #if it doesn't yet exist), or empty string if opunk::console is unavailable. + #The default console is normally constructed - and its can_respond settled where safely possible - + #during repl::init. console_spec_resolve auto-attaches it for matching channel pairs. + proc default_console {} { + if {[info exists ::opunk::console::instances::default]} { + #instance exists => opunk::console is loaded - ensure the store unification below applies + set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting + return default + } + if {[catch {package require opunk::console}]} { + return "" + } + #unify the class's probe-byte store with our cooperative store so bytes consumed by + #opunk::Console probe reads are visible to active readers (punk repl etc) + set ::opunk::console::waiting_chunks_arrayvar ::punk::console::input_chunks_waiting + opunk::console::create default stdin stdout + return default + } + + #default timeout for the active can_respond settling probe (see settle_can_respond) + #short enough not to stall first use badly on non-responding consoles - long enough for real + #terminals (incl. moderate ssh latency) to answer a cursor position report. + variable can_respond_probe_timeout_ms + if {![info exists can_respond_probe_timeout_ms]} { + set can_respond_probe_timeout_ms 500 + } + #latch: true while settle_can_respond's own probe is in flight, so get_ansi_response_payload's + #first-use settling trigger cannot recurse into another settle + variable can_respond_settling + if {![info exists can_respond_settling]} { + set can_respond_settling 0 + } + + punk::args::define { + @id -id ::punk::console::settle_can_respond + @cmd -name punk::console::settle_can_respond -summary\ + "Actively settle a console's response capability."\ + -help\ + "Determines whether the console specified can answer ANSI queries, settling the result + on the governing opunk::console instance when one is anchored (instance name spec, or a + channel pair governed by the anchored 'default' instance). Layered determination: + 1. certainty - input at eof/closed -> 0 (no query emitted) + 2. heuristic - input provably not a console/tty and no terminal env hints -> 0 + (no query emitted - piped/CI output streams stay clean) + 3. active probe - a cursor position report (CSI 6n) is emitted and 1 settled only if a + response arrives within -timeout_ms. Only reached in the ambiguous zone + (real consoles, or pipe-presenting terminals such as mintty without + winpty) where queries are already routine. + Unmatched input consumed while waiting is preserved in punk::console::input_chunks_waiting. + For an unanchored object-value or plain channel-pair spec the result is returned but cannot + be persisted." + @opts + -force -type none -help\ + "Re-probe and re-settle even if capability is already settled." + -timeout_ms -type integer -default "" -help\ + "Probe timeout in ms. Empty uses punk::console::can_respond_probe_timeout_ms." + @values -min 1 -max 1 + consolespec -type list -help\ + "Console specification: 2-element {in out} channel list, anchored opunk::console instance + name, or ::opunk::Console object value." + } + proc settle_can_respond {args} { + variable can_respond_probe_timeout_ms + set argd [punk::args::parse $args withid ::punk::console::settle_can_respond] + lassign [dict values $argd] leaders opts values received + set opt_force [dict exists $received -force] + set timeout_ms [dict get $opts -timeout_ms] + if {$timeout_ms eq ""} { + set timeout_ms $can_respond_probe_timeout_ms + } + set consolespec [dict get $values consolespec] + + set cinfo [console_spec_resolve $consolespec] + set in [dict get $cinfo in] + set out [dict get $cinfo out] + set cobj [dict get $cinfo object] + + #determine the anchor variable (persistence point) if any + set anchorvar "" + if {[llength $consolespec] == 1} { + set anchorvar [opunk::console::instancevar $consolespec] + } elseif {$cobj ne "" && [info exists ::opunk::console::instances::default] && $cobj eq [set ::opunk::console::instances::default]} { + set anchorvar ::opunk::console::instances::default + } + + if {$cobj ne ""} { + set settled [::opunk::Console::get.o_can_respond $cobj] + if {$settled != -1 && !$opt_force} { + return $settled + } + if {$opt_force && $anchorvar ne ""} { + #unsettle first so the probe's own query is not refused by get_ansi_response_payload + upvar #0 $anchorvar reprobe_obj + ::opunk::Console::set.o_can_respond reprobe_obj -1 + } + } + + #layer 1 - certainty: an eof/closed input can never deliver a response + if {[input_at_eof $in]} { + set verdict 0 + } elseif {![is_input_console_or_tty $in]} { + #layer 2 - strong negative heuristic: plain pipe, no terminal indicators or env hints. + #No query is emitted - piped output streams stay clean. + set verdict 0 + } else { + #layer 3 - active probe (ambiguous zone only): cursor position report + variable can_respond_settling + set capturingregex {(.*)(\x1b\[([0-9]+;[0-9]+)R)$} + set can_respond_settling 1 + try { + if {[catch { + punk::console::internal::get_ansi_response_payload -expected_ms $timeout_ms -console [list $in $out] "\033\[6n" $capturingregex + } payload]} { + set verdict 0 + } else { + set verdict [expr {$payload ne "" ? 1 : 0}] + } + } finally { + set can_respond_settling 0 + } + } + + if {$anchorvar ne ""} { + upvar #0 $anchorvar settle_obj + ::opunk::Console::set.o_can_respond settle_obj $verdict + } + return $verdict + } + + punk::args::define { + @id -id ::punk::console::get_size + @cmd -name punk::console::get_size -summary\ + "Console size in character cells."\ + -help\ + "Size of the console as a dict with keys columns and rows. + Mechanisms are tried fastest-first: chan configure -winsize on the output channel, then ANSI + cursor-report mechanisms (timed per console on first use), then tput, defaulting to 80x24. + ANSI mechanisms are skipped when the input side cannot answer queries: a supplied console + object with settled can_respond 0 is authoritative (its default_size is returned), otherwise + heuristic channel/eof detection applies. A settled can_respond 1 bypasses the heuristics. + NOTE: argument parsing in this proc is manual for performance - this definition is documentation." + @opts + -console -default {stdin stdout} -type list -help\ + "Console specification: a 2-element {in out} channel list, an anchored opunk::console + instance name, or an ::opunk::Console object value." + @values -min 0 -max 1 + inoutchannels -type list -optional 1 -help\ + "Legacy positional console specification (same forms accepted as -console)." + } + proc get_size {args} { + #manual argument parsing - performance sensitive path (see PUNKARGS id ::punk::console::get_size for documentation) + switch -exact -- [llength $args] { + 0 { + set consolespec {stdin stdout} + } + 1 { + set consolespec [lindex $args 0] ;#legacy positional inoutchannels - accepts any console spec form + } + 2 { + if {[lindex $args 0] ne "-console"} { + error "get_size: expected ?consolespec? or -console - got '$args'" + } + set consolespec [lindex $args 1] + } + default { + error "get_size: expected ?consolespec? or -console - got '$args'" + } + } + set cinfo [console_spec_resolve $consolespec] + set in [dict get $cinfo in] + set out [dict get $cinfo out] + set cobj [dict get $cinfo object] + set inoutchannels [list $in $out] ;#canonical channel pair - also the get_size_mechanism cache key + set tried_mechlist [list] + + #fastest mechanism if available - use Tcl's inbuilt -winsize key from chan configure if available - this is much faster than any ANSI mechanism + #unknown which platforms support this. + if {![catch {get_size_using_chanconfigure $inoutchannels} sizedict]} { + return $sizedict + } + lappend tried_mechlist "chanconfigure" + + #settled facts on a supplied console object override the heuristics in both directions + set can_respond -1 + if {$cobj ne ""} { + set can_respond [::opunk::Console::get.o_can_respond $cobj] + if {$can_respond == -1} { + #first-use active settling - only for persistable specs (anchored instance name, or + #the auto-attached default instance) so unanchored object values can't cause a probe + #per call. settle_can_respond only emits a query in the ambiguous zone. + if {[llength $consolespec] == 1 || ([info exists ::opunk::console::instances::default] && $cobj eq [set ::opunk::console::instances::default])} { + set can_respond [settle_can_respond $consolespec] + } + } + } + if {$can_respond == 0} { + #console object is settled as unable to answer queries - its default_size is authoritative. + #(tput deliberately not consulted here: it reflects the controlling terminal environment, + # not the supplied channel pair) + return [::opunk::Console::default_size $cobj] + } + + if {$can_respond != 1 && ![is_input_console_or_tty $in]} { + #input channel can't answer cursor position queries - skip the ANSI cursor-report mechanisms + #entirely (they would hang or timeout waiting for a response that cannot arrive) + if {![catch {get_size_using_tput $inoutchannels} sizedict]} { + return $sizedict + } + #fall back to a conventional default size rather than erroring, so callers laying out text + #(help tables etc) still function with piped/redirected input. + return [dict create columns 80 rows 24] + } + + + variable is_vt52 + if {$is_vt52} { + #vt52 doesn't support cursor save/restore or cursor position reports. + if {![catch {get_size_using_tput $inoutchannels} sizedict]} { + return $sizedict + } + lappend tried_mechlist "tput" + error "can't get console size. Tried mechanisms: $mechlist" + } + + variable get_size_mechanism ;#dict keyed on terminal ident. (currently just list of inoutchannels but may be something else in future such as terminal object or ident string) + if {![dict exists $get_size_mechanism $inoutchannels]} { + set try_order [list] + #call each mechanism once to see if it works - and to ensure we don't include initial run in our timings. + #we will also use the results for our initial return of the size. + set successful_mechs [list] + set sizedict [dict create] + if {![catch {get_size_using_cursorrestore $inoutchannels} result]} { + lappend successful_mechs "cursorrestore" + if {![dict size $sizedict]} { + set sizedict $result + } + } + if {![catch {get_size_using_cursormove $inoutchannels} result]} { + lappend successful_mechs "cursormove" + if {![dict size $sizedict]} { + set sizedict $result + } + } + if {![catch {get_size_using_tput $inoutchannels} result] } { + lappend successful_mechs "tput" + if {![dict size $sizedict]} { + set sizedict $result + } + } + set timings [list] + set t_ms 250 ;#default timerate is 1000ms - we are trying at least 3 mechanisms so 1000ms is a bit long for this test as it can slow down the first call to get_size significantly. + foreach sm $successful_mechs { + catch { + set timing_result [timerate {get_size_using_$sm $inoutchannels} $t_ms] + set micros [expr {int([lindex $timing_result 0])}] + lappend timings [list $micros $sm] + } + } + set sorted [lsort -integer -index 0 $timings] + if {[llength $sorted] > 0} { + set try_order [lmap t $sorted {lindex $t 1}] + } else { + set try_order [list] + } + dict set get_size_mechanism $inoutchannels $try_order + return $sizedict + } + + foreach mech [dict get $get_size_mechanism $inoutchannels] { + if {![catch {get_size_using_$mech $inoutchannels} sizedict]} { + return $sizedict + } + lappend tried_mechlist $mech + } + + #if {![catch {get_size_using_cursorrestore $inoutchannels} sizedict]} { + # return $sizedict + #} + #lappend tried_mechlist "cursorrestore" + + #if {![catch {get_size_using_cursormove $inoutchannels} sizedict]} { + # return $sizedict + #} + #lappend tried_mechlist "cursormove" + + #if {![catch {get_size_using_tput $inoutchannels} sizedict]} { + # return $sizedict + #} + #lappend tried_mechlist "tput" + + error "can't get console size. Tried mechanisms: $tried_mechlist" + } + + proc get_size_using_chanconfigure {{inoutchannels {stdin stdout}}} { + set out [lindex $inoutchannels 1] + set outconf [chan configure $out] + if {[dict exists $outconf -winsize]} { + #this mechanism is much faster than ansi cursor movements + #REVIEW check if any x-platform anomalies with this method? + #can -winsize key exist but contain erroneous info? We will check that we get 2 ints at least + lassign [dict get $outconf -winsize] cols lines + if {[string is integer -strict $cols] && [string is integer -strict $lines]} { + return [dict create columns $cols rows $lines] + } + } + error "chan configure method of getting console size not supported or failed to get valid size info" + + #we don't need to care about the input channel if chan configure on the output can give us the info. + #short circuit ansi cursor movement method if chan configure supports the -winsize value + set outconf [chan configure $out] + if {[dict exists $outconf -winsize]} { + #this mechanism is much faster than ansi cursor movements + #REVIEW check if any x-platform anomalies with this method? + #can -winsize key exist but contain erroneous info? We will check that we get 2 ints at least + lassign [dict get $outconf -winsize] cols lines + if {[string is integer -strict $cols] && [string is integer -strict $lines]} { + return [dict create columns $cols rows $lines] + } + #continue on to ansi mechanism if we didn't get 2 ints + } + + } + + proc get_size_using_tput {{inoutchannels {stdin stdout}}} { + set tputcmd [auto_execok tput] + if {$tputcmd eq ""} { + error "tput command not found - cannot use tput method to get console size" + } + lassign [exec {*}$tputcmd lines cols] lines cols + return [dict create columns $cols rows $lines] + } + + proc get_size_using_cursormove {{inoutchannels {stdin stdout}}} { + set out [lindex $inoutchannels 1] + #we can't reliably use [chan names] for stdin,stdout. There could be stacked channels and they may have a names such as file22fb27fe810 + #chan eof is faster whether chan exists or not than + if {[catch {chan eof $out} is_eof]} { + error "punk::console::get_size_using_cursormove output channel $out seems to be closed ([info level 1])" + } else { + if {$is_eof} { + error "punk::console::get_size_using_cursormove eof on output channel $out ([info level 1])" + } + } + + lassign [get_cursor_pos_list $inoutchannels] start_row start_col + if {[catch { + #some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that. + #This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere. + puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::move 2000 2000] + lassign [get_cursor_pos_list $inoutchannels] lines cols + puts -nonewline $out [punk::ansi::move $start_row $start_col][punk::ansi::cursor_on];flush stdout + set result [dict create columns $cols rows $lines] + } errM]} { + puts -nonewline $out [punk::ansi::move $start_row $start_col] + puts -nonewline $out [punk::ansi::cursor_on] + error "$errM" + } else { + return $result + } + } + + #faster than get_size when it is using ansi mechanism - but uses cursor_save - which we may want to avoid if calling during another operation which uses cursor save/restore + proc get_size_using_cursorrestore {{inoutchannels {stdin stdout}}} { + lassign $inoutchannels in out + set outconf [chan configure $out] + #don't use shortcut mechanisms - this function is intended to specificall use the cursor_save/restore method + + if {[catch {chan eof $out} is_eof]} { + error "punk::console::get_size_using_cursorrestore output channel $out seems to be closed ([info level 1])" + } else { + if {$is_eof} { + error "punk::console::get_size_using_cursorrestore eof on output channel $out ([info level 1])" + } + } + + if {[catch { + #some terminals (conemu on windows) scroll the viewport when we make a big move down like this - a move to 1 1 immediately after cursor_save doesn't seem to fix that. + #This issue also occurs when switching back from the alternate screen buffer - so perhaps that needs to be addressed elsewhere. + puts -nonewline $out [punk::ansi::cursor_off][punk::ansi::cursor_save_dec][punk::ansi::move 2000 2000] + lassign [get_cursor_pos_list $inoutchannels] lines cols + puts -nonewline $out [punk::ansi::cursor_restore][punk::console::cursor_on];flush $out + set result [dict create columns $cols rows $lines] + } errM]} { + puts -nonewline $out [punk::ansi::cursor_restore_dec] + puts -nonewline $out [punk::ansi::cursor_on] + error "$errM" + } else { + return $result + } + } + + + proc get_dimensions {{inoutchannels {stdin stdout}}} { + lassign [get_size $inoutchannels] _c cols _l lines + return "${cols}x${lines}" + } + + + + #the (xterm?) CSI 18t query is supported by *some* terminals + proc get_xterm_size {{inoutchannels {stdin stdout}}} { + set capturingregex {(.*)(\x1b\[8;([0-9]+;[0-9]+)t)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[18t" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + lassign [split $payload {;}] rows cols + return [list columns $cols rows $rows] + } + proc get_xterm_pixels {{inoutchannels {stdin stdout}}} { + set capturingregex {(.*)(\x1b\[4;([0-9]+;[0-9]+)t)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[14t" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + lassign [split $payload {;}] height width + return [list width $width height $height] + } + + + + + + proc dec_get_mode_line_wrap {{inoutchannels {stdin stdout}}} { + set capturingregex {(.*)(\x1b\[\?7;([0-9]+)\$y)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[?7\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + #Terminals generally default to LNM being reset (off) ie enter key sends a lone + #windows terminal defaults to LNM on, but wezterm on windows default to LNM off + #LNM on sends both and ?? + proc ansi_get_mode_LNM {{inoutchannels {stdin stdout}}} { + set capturingregex {(.*)(\x1b\[20;([0-9]+)\$y)$} ;#must capture prefix,entire-response,response-payload + set request "\x1b\[20\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $inoutchannels $request $capturingregex] + return $payload + } + + + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::dec_get_mode + @cmd -name punk::console::dec_get_mode\ + -summary\ + {Get DEC mode}\ + -help\ + {Get DEC mode by sending to the console + the sequence: + ESC [ ? $ p + Where is an integer + formed from the supplied ${$I}mode${$NI} value. + + The terminal should respond with a sequence of the form: + ESC [ ? ; $ y + where is one of the statuses: + 0 - mode not recognised + 1 - mode is set + 2 - mode is unset + 3 - mode is permanently set + 4 - mode is permanently unset + + The response is automatically retrieved from the terminal + and so should not be displayed unless there is such a + significant delay that the request times out. + + The value of is returned by the dec_get_mode function. + + The delay in retrieving a terminal response can range from + a few ms to 10s of ms depending on the terminal or other + runtime conditions. + In some cases it may be beneficial to call dec_has_mode first, + (which is cached) to reduce the number of queries to the terminal. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + @values -min 1 -max 1 + mode -type {int|string} -multiple 0 -help\ + "integer for DEC mode, or name as in the dict: + ::punk::ansi::decmode_names + See also the command: ${$B}dec_modes${$N}" + }] + } + #DECRPM responses e.g: + # \x1b\[?7\;1\$y + # \x1b\[?7\;2\$y + #where 1 = set, 2 = unset. (0 = mode not recognised, 3 = permanently set, 4 = permanently unset) + proc dec_get_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_get_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set mode [dict get $values mode] + + if {[string is integer -strict $mode]} { + set m $mode + } else { + upvar ::punk::ansi::decmode_names decmode_names + if {[dict exists $decmode_names $mode]} { + set m [dict get $decmode_names $mode] + } else { + error "punk::console::dec_get_mode unrecognised mode '$mode'. Known mode names: [dict keys $decmode_names]" + } + } + set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[\?%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload + set request "\x1b\[?$m\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $terminal -passthrough $passthrough $request $capturingregex] + return $payload + } + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::dec_set_mode + @cmd -name punk::console::dec_set_mode\ + -summary\ + {Set DEC mode(s) (DECSET)}\ + -help\ + {Set DEC mode(s) by sending to the console + the DECSET sequence: + ESC [ ? h + Where is a colon delimited set of integers + formed from the supplied ${$I}mode${$NI} values. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 1 -max -1 + mode -type {int|string} -multiple 1 -help\ + "integer for DEC mode, or name as in the dict: + ::punk::ansi::decmode_names + See also the command: ${$B}dec_modes${$N}" + }] + } + #todo - should accept multiple mode nums/names at once + proc dec_set_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_set_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set modes [dict get $values mode] ;#multiple + + set modelist [list] + foreach num_or_name $modes { + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::decmode_names decmode_names + if {[dict exists $decmode_names $num_or_name]} { + set m [dict get $decmode_names $num_or_name] + } else { + error "punk::console::dec_set_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $decmode_names]" + } + } + lappend modelist $m + } + set modes_string [join $modelist {;}] + set term_out [lindex $terminal 1] + puts -nonewline $term_out "\x1b\[?${modes_string}h" + } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::dec_unset_mode + @cmd -name punk::console::dec_unset_mode\ + -summary\ + {Unset DEC mode(s) (DECRST)}\ + -help\ + {Unset DEC mode(s) by sending to the console + the DECRST sequence: + ESC [ ? l + Where is a colon delimited set of integers + formed from the supplied ${$I}mode${$NI} values. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 1 -max -1 + mode -type {int|string} -multiple 1 -help\ + "integer for DEC mode, or name as in the dict: + ::punk::ansi::decmode_names + See also the command: ${$B}dec_modes${$N}" + }] + } + proc dec_unset_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_unset_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set modes [dict get $values mode] ;#multiple + + set modelist [list] + foreach num_or_name $modes { + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::decmode_names decmode_names + if {[dict exists $decmode_names $num_or_name]} { + set m [dict get $decmode_names $num_or_name] + } else { + error "punk::console::dec_unset_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $decmode_names]" + } + } + lappend modelist $m + } + set modes_string [join $modelist {;}] + set term_out [lindex $terminal 1] + puts -nonewline $term_out "\x1b\[?${modes_string}l" + } + + variable dec_has_mode_cache + set dec_has_mode_cache [dict create] + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::dec_has_mode + @cmd -name punk::console::dec_has_mode\ + -summary\ + {Check if console supports a particular DEC mode}\ + -help\ + {Check if console supports a particular DEC mode by emitting query to the console using + the sequence: + ESC [ ? $ p + Where is an integer identifier. + + Will return zero if the mode is unsupported, 1|2|3|4 if supported. + + The result is cached. + Cached values of 1 or 2 may not represent current state. + ${$B}dec_get_mode${$N} or ${$B}dec_has_mode -refresh${$N} should be + used if up-to-date current state of a supported mode is required. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + -refresh -type none -help\ + "Force a re-test of the mode." + -return -type string -choices {dict result} -default result -choicelabels { + dict\ + "Return a dict with keys source and result + source indicates whether the result came + from query or cache." + } + @values -min 1 -max 1 + mode -type {int|string} -help\ + "integer for DEC mode, or name as in the dict: + ::punk::ansi::decmode_names" + }] + } + proc dec_has_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_has_mode] + lassign [dict values $argd] leaders opts values received + set console [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set num_or_name [dict get $values mode] + set do_refresh [dict exists $received -refresh] + set return [dict get $opts -return] + + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::decmode_names decmode_names + if {[dict exists $decmode_names $num_or_name]} { + set m [dict get $decmode_names $num_or_name] + } else { + error "punk::console::dec_get_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $decmode_names]" + } + } + variable dec_has_mode_cache + #make sure we cache on both console and passthrough + set cachekey "$console $passthrough" + if {$do_refresh} { + if {[dict exists $dec_has_mode_cache $cachekey $m]} { + dict unset dec_has_mode_cache $cachekey $m + } + } + + if {![dict exists $dec_has_mode_cache $cachekey $m]} { + set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[\?%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload + set request "\x1b\[?$m\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $console -passthrough $passthrough $request $capturingregex] + #set has_mode [expr {$payload != 0}] + #we can use the payload result as the response as non-zero responses evaluate to true + set has_mode $payload + if {$has_mode ne ""} { + dict set dec_has_mode_cache $cachekey $m $has_mode + set source "query" + } else { + #don't cache an empty/failed response - review + set has_mode 0 + set source "failedquery" + } + } else { + set has_mode [dict get $dec_has_mode_cache $cachekey $m] + set source "cache" + } + if {$return eq "dict"} { + return [dict create result $has_mode source $source] + } + return $has_mode + } + + lappend PUNKARGS [list { + @id -id ::punk::console::dec_modes + @cmd -name punk::console::dec_modes\ + -summary\ + {Show table of DEC modes}\ + -help\ + {Show table of DEC modes with basic information.} + @opts + -console -type list -minsize 2 -default {stdin stdout} + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + -test -type none -help\ + "Test current value/support for each mode" + -supported -type none -help\ + "Limit results to supported DEC modes" + @values -min 0 -max -1 + match -type indexset|string -multiple 1 -optional 1 -help\ + "Match code or name" + }] + proc dec_modes {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_modes] + lassign [dict values $argd] leaders opts values received + set terminal [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set do_test [dict exists $received -test] + set only_supported [dict exists $received -supported] + if {[dict exists $values match]} { + set matches [dict get $values match] + } else { + set matches {} + } + + upvar ::punk::ansi::decmode_data decmode_data + upvar ::punk::ansi::decmode_names decmode_names + set t [textblock::class::table new "Dec Modes"] + $t configure -show_header 1 -show_hseps 1 + $t add_column -headers Code + $t add_column -headers Names + $t add_column -headers Origin + $t add_column -headers Description + $t add_column -headers set-state + $t add_column -headers reset-state + if {$do_test} { + $t add_column -headers Status + } + set names [dict keys $decmode_names] + if {[llength $matches] == 0} { + #show all entries + set codes [dict keys $decmode_data] + } else { + set codes [list] + foreach m $matches { + if {[punk::lib::is_indexset $m]} { + set defaultmax 10000 ;#some codes are very high e.g foot IME 737769 - but we don't want to default test/scan for hours + #todo set max to actual largest value from indexset + lappend codes {*}[punk::lib::indexset_resolve -base 1 $defaultmax $m] + } else { + foreach nm $names { + if {[string match -nocase $m $nm]} { + set code [dict get $decmode_names $nm] + #only suppress dupes if sequential (display loop will still show multiple entries for the code if it has multiple origin entries) + if {[lindex $codes end] ne $code} { + lappend codes [dict get $decmode_names $nm] + } + } + } + } + } + } + #dict for {code items} $decmode_data {} + foreach code $codes { + if {[dict exists $decmode_data $code]} { + set items [dict get $decmode_data $code] + } else { + set items [list [dict create origin ? description "" names "" set-state "" reset-state ""]] + } + set colour "" + set set_state_colour "" + set reset_state_colour "" + set RST "" + if {$do_test} { + #dec_has_mode can be cached - in which case only 0|3|4 can be relied upon without re-querying + set hasmode_dict [dec_has_mode -console $terminal -passthrough $passthrough -return dict $code] + switch -- [dict get $hasmode_dict result] { + 0 { + if {$only_supported} { + continue + } + if {[dict get $hasmode_dict source] eq "failedquery"} { + set testresult "no response" + } else { + set testresult 0 + } + } + 1 - 2 { + if {[dict get $hasmode_dict source] eq "cache"} { + #a terminal query is required + set testresult [dec_get_mode -console $terminal -passthrough $passthrough $code] + } else { + set testresult [dict get $hasmode_dict result] + if {![string is integer -strict $testresult]} { + set testresult "No response" + } + } + } + default { + #3|4 - permanently enabled or permanently disabled + set testresult [dict get $hasmode_dict result] + } + } + + switch -- $testresult { + 0 { + set colour [punk::ansi::a+ red bold] + } + 1 { + set colour [punk::ansi::a+ green] + set set_state_colour $colour + } + 2 { + set colour [punk::ansi::a+ yellow bold] + set reset_state_colour $colour + } + 3 { + set colour [punk::ansi::a+ green] + set set_state_colour $colour + } + 4 { + set colour [punk::ansi::a+ yellow bold] + set reset_state_colour $colour + } + default { + #failedquery + set colour [punk::ansi::a+ red bold] + } + } + if {$colour ne ""} { + set RST "\x1b\[m" + } + set testdisplay $colour$testresult$RST + } else { + if {$only_supported} { + #dec_has_mode still queries terminal - but is cached if a response was received + if {[dec_has_mode -console $terminal -passthrough $passthrough $code] == 0} { + continue + } + } + } + foreach itm $items { + set code $colour$code$RST + set names $colour[dict get $itm names]$RST + set origin [dict get $itm origin] + set desc [dict get $itm description] + set set_state $set_state_colour[dict get $itm set-state]$RST + set reset_state $reset_state_colour[dict get $itm reset-state]$RST + set row [list $code [join $names \n] $origin $desc $set_state $reset_state] + if {$do_test} { + lappend row $testdisplay + } + $t add_row $row + } + } + set out [$t print] + $t destroy + return $out + } + + variable ansi_has_mode_cache + set ansi_has_mode_cache [dict create] + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::ansi_has_mode + @cmd -name punk::console::ansi_has_mode\ + -summary\ + {Check if console supports a particular ANSI mode}\ + -help\ + {Check if console supports a particular ANSI mode by emitting query to the console using + the sequence: + ${$B}ESC [ $ p${$N} + Where ${$B}${$N} is an integer identifier. + } + @opts + #review - problem with 's ansi_has_mode' + #-console -type list -typesynopsis {{${$I}inputchan${$NI} ${$I}outputchan${$NI}}} -minsize 2 -default {stdin stdout} + -console -type list -minsize 2 -default {stdin stdout} + -refresh -type none + -return -type string -choices {dict result} -default result -choicelabels { + dict\ + "Return a dict with keys source and result + source indicates whether the result came + from query or cache." + } + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + @values -min 1 -max 1 + mode -type {int|string} -help\ + "integer for ANSI mode, or name as in the dict: + ::punk::ansi::ansimode_names" + }] + } + proc ansi_has_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::ansi_has_mode] + lassign [dict values $argd] leaders opts values received + set console [dict get $opts -console] + set num_or_name [dict get $values mode] + set return [dict get $opts -return] + set passthrough [dict get $opts -passthrough] + set do_refresh [dict exists $received -refresh] + + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::ansimode_names ansimode_names + if {[dict exists $ansimode_names $num_or_name]} { + set m [dict get $ansimode_names $num_or_name] + } else { + error "punk::console::ansi_has_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $ansimode_names]" + } + } + variable ansi_has_mode_cache + #make sure we cache on both console and passthrough + set cachekey "$console $passthrough" + if {$do_refresh} { + if {[dict exists $ansi_has_mode_cache $cachekey $m]} { + dict unset ansi_has_mode_cache $cachekey $m + } + } + + if {![dict exists $ansi_has_mode_cache $cachekey $m]} { + set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload + set request "\x1b\[$m\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $console -passthrough $passthrough $request $capturingregex] + #set has_mode [expr {$payload != 0}] + set has_mode $payload + if {$has_mode ne ""} { + dict set ansi_has_mode_cache $cachekey $m $has_mode + set source "query" + } else { + #don't cache an empty/failed response - review + set has_mode 0 + set source "failedquery" + } + } else { + set has_mode [dict get $ansi_has_mode_cache $cachekey $m] + set source "cache" + } + if {$return eq "dict"} { + return [dict create result $has_mode source $source] + } + return $has_mode + } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::ansi_set_mode + @cmd -name punk::console::ansi_set_mode\ + -summary\ + {Set ANSI mode(s) (SM)}\ + -help\ + {Set ANSI mode(s) by sending to the console + the SM sequence: + ESC [ h + Where is a colon delimited set of integers + formed from the supplied ${$I}mode${$NI} values. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 1 -max -1 + mode -type {int|string} -multiple 1 -help\ + "integer for ANSI mode, or name as in the dict: + ::punk::ansi::ansimode_names + See also the command: ${$B}ansi_modes${$N}" + }] + } + proc ansi_set_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::ansi_set_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set modes [dict get $values mode] ;#multiple + + set modelist [list] + foreach num_or_name $modes { + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::ansimode_names ansimode_names + if {[dict exists $ansimode_names $num_or_name]} { + set m [dict get $ansimode_names $num_or_name] + } else { + error "punk::console::ansi_set_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $ansimode_names]" + } + } + lappend modelist $m + } + set modes_string [join $modelist {;}] + set term_out [lindex $terminal 1] + puts -nonewline $term_out "\x1b\[${modes_string}h" + } + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::ansi_unset_mode + @cmd -name punk::console::ansi_unset_mode\ + -summary\ + {Unset ANSI mode(s) (RM)}\ + -help\ + {Unset ANSI mode(s) by sending to the console + the RM sequence: + ESC [ l + Where is a colon delimited set of integers + formed from the supplied ${$I}mode${$NI} values. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 1 -max -1 + mode -type {int|string} -multiple 1 -help\ + "integer for ANSI mode, or name as in the dict: + ::punk::ansi::ansimode_names + See also the command: ${$B}ansi_modes${$N}" + }] + } + proc ansi_unset_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::ansi_unset_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set modes [dict get $values mode] ;#multiple + + set modelist [list] + foreach num_or_name $modes { + if {[string is integer -strict $num_or_name]} { + set m $num_or_name + } else { + upvar ::punk::ansi::ansimode_names ansimode_names + if {[dict exists $ansimode_names $num_or_name]} { + set m [dict get $ansimode_names $num_or_name] + } else { + error "punk::console::ansi_unset_mode unrecognised mode '$num_or_name'. Known mode names: [dict keys $ansimode_names]" + } + } + lappend modelist $m + } + set modes_string [join $modelist {;}] + set term_out [lindex $terminal 1] + puts -nonewline $term_out "\x1b\[${modes_string}l" + } + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::ansi_get_mode + @cmd -name punk::console::ansi_get_mode\ + -summary\ + {Get ANSI mode}\ + -help\ + {Get ANSI mode by sending to the console + the sequence: + ESC [ $ p + Where is an integer + formed from the supplied ${$I}mode${$NI} value. + + The terminal should respond with a sequence of the form: + ESC [ ; $ y + where is one of the statuses: + 0 - mode not recognised + 1 - mode is set + 2 - mode is unset + 3 - mode is permanently set + 4 - mode is permanently unset + + The response is automatically retrieved from the terminal + and so should not be displayed unless there is such a + significant delay that the request times out. + + The value of is returned by the ansi_get_mode function. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + @values -min 1 -max 1 + mode -type {int|string} -multiple 0 -help\ + "integer for ANSI mode, or name as in the dict: + ::punk::ansi::ansimode_names + See also the command: ${$B}ansi_modes${$N}" + }] + } + #DECRPM responses e.g: + # \x1b\[12\;1\$y + # \x1b\[?7\;2\$y + #where 1 = set, 2 = unset. (0 = mode not recognised, 3 = permanently set, 4 = permanently unset) + proc ansi_get_mode {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::ansi_get_mode] + lassign [dict values $argd] leaders opts values + set terminal [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set mode [dict get $values mode] + + if {[string is integer -strict $mode]} { + set m $mode + } else { + upvar ::punk::ansi::ansimode_names ansimode_names + if {[dict exists $ansimode_names $mode]} { + set m [dict get $ansimode_names $mode] + } else { + error "punk::console::ansi_get_mode unrecognised mode '$mode'. Known mode names: [dict keys $ansimode_names]" + } + } + set capturingregex [string map [list %MODE% $m] {(.*)(\x1b\[%MODE%;([0-9]+)\$y)$}] ;#must capture prefix,entire-response,response-payload + set request "\x1b\[$m\$p" + set payload [punk::console::internal::get_ansi_response_payload -console $terminal -passthrough $passthrough $request $capturingregex] + return $payload + } + #todo ansi_unset_mode + + lappend PUNKARGS [list { + @id -id ::punk::console::ansi_modes + @cmd -name punk::console::ansi_modes\ + -summary\ + {Show table of ANSI modes}\ + -help\ + {Show table of ANSI modes with basic information.} + @opts + -console -type list -minsize 2 -default {stdin stdout} + ${[punk::args::resolved_def -types opts ::punk::console::internal::get_ansi_response_payload -passthrough]} + -test -type none -help\ + "Test current value/support for each mode" + -supported -type none -help\ + "Limit results to supported ANSI modes" + @values -min 0 -max -1 + match -type indexset|string -multiple 1 -optional 1 -help\ + "Match code or name" + }] + proc ansi_modes {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::ansi_modes] + lassign [dict values $argd] leaders opts values received + set terminal [dict get $opts -console] + set passthrough [dict get $opts -passthrough] + set do_test [dict exists $received -test] + if {[dict exists $values match]} { + set matches [dict get $values match] + } else { + set matches {} + } + set only_supported [dict exists $received -supported] + + upvar ::punk::ansi::ansimode_data ansimode_data + upvar ::punk::ansi::ansimode_names ansimode_names + + set t [textblock::class::table new "ANSI Modes"] + $t configure -show_header 1 -show_hseps 1 + $t add_column -headers Code + $t add_column -headers Names + $t add_column -headers Origin + $t add_column -headers Description + $t add_column -headers set-state + $t add_column -headers reset-state + if {$do_test} { + $t add_column -headers Status + } + set names [dict keys $ansimode_names] + if {[llength $matches] == 0} { + #show all entries + set codes [dict keys $ansimode_data] + } else { + set codes [list] + foreach m $matches { + if {[punk::lib::is_indexset $m]} { + set iparts [split $m ,] + #determine the largest index requested - which may be greater than the defined range if explicitly specified + #(allow scanning for unusual ansi modes above 22 - *some* known to exist - private modes should use DECSET DECRESET instead) + #above 22 values seem to be mainly from SCOANSI and Wyse terminals + + set maxint [dict size $ansimode_data] + foreach i $iparts { + if {[string is integer -strict $i]} { + if {$i > $maxint} { + set maxint $i + } + } else { + if {![string match *end* $i]} { + lassign [split [string map [list .. \uFFEF] $i] \uFFEF] a b + if {$a > $b} { + if {$a > $maxint} { + set maxint $a + } + } else { + if {$b > $maxint} { + set maxint $b + } + } + } + } + } + puts "---> maxint: $maxint" + lappend codes {*}[punk::lib::indexset_resolve -base 1 $maxint $m] + } else { + foreach nm $names { + if {[string match -nocase $m $nm]} { + set code [dict get $ansimode_names $nm] + #only suppress dupes if sequential (display loop will still show multiple entries for the code if it has multiple origin entries) + if {[lindex $codes end] ne $code} { + lappend codes [dict get $ansimode_names $nm] + } + } + } + } + } + } + + #dict for {code items} $ansimode_data {} + foreach code $codes { + if {[dict exists $ansimode_data $code]} { + set items [dict get $ansimode_data $code] + } else { + set items [list [dict create origin ? description "" names "" set-state "" reset-state ""]] + } + set colour "" + set set_state_colour "" + set reset_state_colour "" + set RST "" + if {$do_test} { + set hasmode_dict [ansi_has_mode -console $terminal -passthrough $passthrough -return dict $code] + switch -- [dict get $hasmode_dict result] { + 0 { + if {$only_supported} { + continue + } + if {[dict get $hasmode_dict source] eq "failedquery"} { + set testresult "no response" + } else { + set testresult 0 + } + } + 1 - 2 { + if {[dict get $hasmode_dict source] eq "cache"} { + #a terminal query is required + set testresult [ansi_get_mode -console $terminal -passthrough $passthrough $code] + } else { + set testresult [dict get $hasmode_dict result] + if {![string is integer -strict $testresult]} { + set testresult "No response" + } + } + } + default { + #3|4 - permanently enabled or permanently disabled + set testresult [dict get $hasmode_dict result] + } + } + + #set testresult [ansi_get_mode $code] + switch -- $testresult { + 0 { + set colour [punk::ansi::a+ red bold] + } + 1 { + set colour [punk::ansi::a+ green] + set set_state_colour $colour + } + 2 { + set colour [punk::ansi::a+ yellow bold] + set reset_state_colour $colour + } + 3 { + set colour [punk::ansi::a+ green] + set set_state_colour $colour + } + 4 { + set colour [punk::ansi::a+ yellow bold] + set reset_state_colour $colour + } + default { + #unexpected + } + } + if {$colour ne ""} { + set RST "\x1b\[m" + } + set testdisplay $colour$testresult$RST + } else { + if {$only_supported} { + #ansi_has_mode still queries terminal - but is cached if a response was received + if {[ansi_has_mode -console $terminal -passthrough $passthrough $code] == 0} { + continue + } + } + } + foreach itm $items { + set code $colour$code$RST + set names $colour[dict get $itm names]$RST + set origin [dict get $itm origin] + set desc [dict get $itm description] + set set_state $set_state_colour[dict get $itm set-state]$RST + set reset_state $reset_state_colour[dict get $itm reset-state]$RST + + set row [list $code [join $names \n] $origin $desc $set_state $reset_state] + if {$do_test} { + lappend row $testdisplay + } + $t add_row $row + } + } + set out [$t print] + $t destroy + return $out + } + + #see https://vt100.net/dec/ek-vt520-rm.pdf + set DECRQSS_DATA { + "Assign Color" DECAC ",|" + "Alternate Text Color" DECATC ",\}" + "CRT Saver Timing" DECCRTST "-q" + "Down Line Load Allocation" DECDLDA ",z" + "Energy Save Timing" DECSEST "-r" + "Select Auto Repeat Rate" DECARR "-p" + "Select Active Status Display" DECSASD "$\}" + "Select Attribute Change Extent" DECSACE "*x" + "Set Character Attribute" DECSCA "\"q" + "Select Communication Port" DECSCP "*u" + "Set Columns Per Page" DECSCPP "\$|" + "Set Conformance Level" DECSCL "\"p" + "Select Communication Speed" DECSCS "*r" + "Set Cursor Style" DECSCUSR " q" + "Select Disconnect Delay Time" DECSDDT "\$q" + "Select Digital Printed Data Type" DECSDPT "(p" + "Select Flow Control Type" DECSFC "*s" + "Set Key Click Volume" DECSKCV " r" + "Set Lock Key Style" DECSLCK " v" + "Set Left and Right Margins" DECSLRM "s" + "Set Lines Per Page" DECSLPP "t" + "Set Margin Bell Volume" DECSMBV " u" + "Set Number of Lines per Screen" DECSNLS "*|" + "Session Page Memory Allocation" DECSPMA ",x" + "Set Port Parameter" DECSPP "\+w" + "Select ProPrinter Character Set" DECSPPCS "*p" + "Select Printer Type" DECSPRTT "\$s" + "Select Refresh Rate" DECSRFR "\"t" + "Set Scroll Speed" DECSSCLS " p" + "Set Status Line Type" DECSSDT "$~" + "Select Set-Up Language" DECSSL "p" + "Set Top and Bottom Margins" DECSTBM "r" + "Select Color Lookup Table" DECSTGLT ")\{" + "Set Transmit Rate Limit" DECSTRL "u" + "Set Warning Bell Volume" DECSWBV " t" + "Select Zero Symbol" DECSZS ",\{" + "Terminal Mode Emulation" DECTME " ~" + "Set Graphic Rendition" SGR "m" + "invalid" invalid ".." + } + set DECRQSS_DICT [dict create] + foreach {desc name str} $DECRQSS_DATA { + dict set DECRQSS_DICT $name $str + } + + + namespace eval argdoc { + proc decrqss_choices_and_help {} { + #return a string for use in the punk::args definition for ::punk::console::dec_request_setting that lists the valid choices for the name argument, and their help text. + #This requires a -choices and -choicelabels pair in the args definition, to allow the help text to be directly associated with the choices. + upvar ::punk::console::DECRQSS_DATA DECRQSS_DATA + set outstr "name -type string -choices \{" + foreach {desc name str} $DECRQSS_DATA { + append outstr " $name" + } + append outstr " \}" + append outstr " -choicecolumns 4 -choicelabels \{" \n + foreach {desc name str} $DECRQSS_DATA { + append outstr " $name \{ ${desc}\}" \n + } + append outstr " \} -help " + append outstr " {Dec setting name to query.}" + return $outstr + + #append argdef " \} -choicecolumns $opt_columns -choicelabels \{" \n + #append argdef " $choicelabelsdict" \n + #append argdef " \} -choiceinfo \{$choiceinfodict\} -unindentedfields \{-choicelabels\}" \n + } + set DECRQSS_CHOICES [::punk::console::argdoc::decrqss_choices_and_help] + lappend PUNKARGS [list { + @id -id ::punk::console::dec_request_setting + @cmd -name punk::console::dec_request_setting\ + -summary\ + {Perform DECRQSS query to get DECRPSS response}\ + -help\ + {DECRQSS query for DEC Selection or Setting. + Return payload from console's DECRPSS response. + } + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 1 -max 1 + ${$DECRQSS_CHOICES} + } + ] + } + #emit DECRQSS and get DECRPSS response + # --------------------- + #NOTE: https://vt100.net/docs/vt510-rm/DECRPSS.html + #response: DCS Ps $ r D...D ST + #Conflict between above doc saying Ps of 0 indicates a valid request and 1 indicates invalid - and the behaviour of windows terminal, wezterm and the reported xterm behaviour + # shown at: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html + #REVIEW + # --------------------- + #Note also on Freebsd xterm we seem to get response ': 1 $ r D...D ST' + #(no leading DCS) - review + + proc dec_request_setting {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::dec_request_setting] + lassign [dict values $argd] leaders opts values + set console [dict get $opts -console] + set name [dict get $values name] + + variable DECRQSS_DICT + if {![dict exists $DECRQSS_DICT $name]} { + error "dec_request_setting unrecognised name $name. Known values: [dict keys $DECRQSS_DICT]" + } + set str [dict get $DECRQSS_DICT $name] + set re_str [string map [list | \\| * \\* \$ \\\$ + \\+ ( \\( ) \\)] $str] ;#regex escaped + #review {[0-9;:]} - too restrictive? - what values can be returned? alnum? - we perhaps at least need to exclude ESC so we don't overmatch + set capturingregex [string map [list %s% $re_str] {(.*)(\x1bP([0-1]\$r[0-9;:]*)(?:%s%){0,1}\x1b\\)$}] ;#must capture prefix,entire-response,response-payload + #todo - handle xterm : [0-1] $ r D...D ST + set request "\x1bP\$q${str}\x1b\\" + set payload [punk::console::internal::get_ansi_response_payload -console $console $request $capturingregex] + set c1 [string index $payload 0] + switch -- $c1 { + 0 { + error "dec_request_setting - terminal doesn't support querying settings for $name '[punk::ansi::ansistring VIEW $request]'" + } + 1 {} + default { + #probable timeout - terminal doesn't recognise/respond + error "dec_request_setting - unrecognised or missing response to request '[punk::ansi::ansistring VIEW $request]'. payload: [punk::ansi::ansistring VIEW $payload]" + } + } + #strip leading 1$r + return [string range $payload 3 end] + } + + namespace eval argdoc { + lappend PUNKARGS [list { + @id -id ::punk::console::cursor_style + @cmd -name punk::console::cursor_style\ + -summary\ + {Get/Set cursor style}\ + -help\ + {Set cursor style using DECSCUSR. + Note that support for DECSCUSR may be inconsistent across terminals. + Some terminals may only support a subset of the possible styles.} + @opts + -console -type list -minsize 2 -default {stdin stdout} + @values -min 0 -max 1 + style -type dict -optional 1 -choicerestricted 0 -choices {0 default 1 block-blink 2 block-steady 3 underline-blink 4 underline-steady 5 beam-blink 6 beam-steady} -choicecolumns 2 -help\ + "Cursor style to set + May be an integer or name as in the choices or a dict of the form { } + as returned by a query of the current style. + + e.g to set block-blink style, you could specify style as 1, block-blink or {1 block-blink} + + After setting to 0 (default) - a subsequent query will not return {0 default} but rather the number and name + of the actual style that the terminal has reset to. + + If not supplied, the current style will be queried and returned as a dict + where num is the key and name is the value, e.g. {2 block-steady}. + This allows querying the current style and then re-setting it after temporarily changing it." + }] + } + + proc cursor_style {args} { + set argd [punk::args::parse $args -cache 1 withid ::punk::console::cursor_style] + lassign [dict values $argd] leaders opts values + set console [dict get $opts -console] + set style "" ;#if not supplied, will query current style instead of setting + set style_num_to_name [dict create {*}{ + 0 default + 1 block-blink + 2 block-steady + 3 underline-blink + 4 underline-steady + 5 beam-blink + 6 beam-steady + }] + set style_name_to_num [dict create {*}{ + default 0 + block-blink 1 + block-steady 2 + underline-blink 3 + underline-steady 4 + beam-blink 5 + beam-steady 6 + }] + if {[dict exists $values style]} { + set style [dict get $values style] + } + if {$style eq ""} { + #query current style using DECRQSS/DECRPSS (dec_request_setting DECSCUSR) + set resultnum [dec_request_setting -console $console DECSCUSR] + if {![string is integer -strict $resultnum]} { + error "cursor_style query for current style returned non-integer result '$resultnum'" + } + if {![dict exists $style_num_to_name $resultnum]} { + error "cursor_style query for current style returned unrecognised style number '$resultnum'. Known styles: [dict keys $style_num_to_name]" + } + set resultname [dict get $style_num_to_name $resultnum] + set result [dict create $resultnum $resultname] + return $result + } + if {[string is integer -strict $style]} { + set style_num $style + if {![dict exists $style_num_to_name $style_num]} { + error "cursor_style unrecognised style number '$style_num'. Known styles: [dict keys $style_num_to_name]" + } + } elseif {[::punk::args::lib::string_is_dict $style]} { + #allow setting cursor using the response from a previous call to query the current style. + if {[dict size $style] != 1} { + error "cursor_style style dict should have exactly one key-value pair {