From 8e4dcc699acd2e529cb14313f265aebabfd59c28 Mon Sep 17 00:00:00 2001 From: Alexander Medvednikov Date: Tue, 21 Apr 2026 15:25:15 +0300 Subject: [PATCH] term: add termui keyboard and mouse keyup event detection (fixes #20440) --- vlib/term/ui/README.md | 6 +- vlib/term/ui/input.v | 1 + vlib/term/ui/input_nix_test.v | 51 +++++++++++++ vlib/term/ui/input_windows.c.v | 35 +++++---- vlib/term/ui/termios_nix.c.v | 133 +++++++++++++++++++++++++-------- 5 files changed, 178 insertions(+), 48 deletions(-) diff --git a/vlib/term/ui/README.md b/vlib/term/ui/README.md index 98bbd2dc4..6ab4d127c 100644 --- a/vlib/term/ui/README.md +++ b/vlib/term/ui/README.md @@ -98,6 +98,6 @@ It can be reduced *drastically*, though, by using the rendering methods built in and by only painting frames when your app's content has actually changed. Q: Why does the module only emit `keydown` events, and not `keyup` like `sokol`/`gg`? -A: It's because of the way terminals emit events. Every key event is received as a keypress, -and there isn't a way of telling terminals to send keyboard events differently, -nor a reliable way of converting these into `keydown` / `keyup` events. +A: `term.ui` emits `key_up` on Windows and on terminals that support the kitty keyboard +protocol. Legacy terminal input still only provides `key_down`, so `key_up` is not +available everywhere. diff --git a/vlib/term/ui/input.v b/vlib/term/ui/input.v index ef307ba00..24e1144d7 100644 --- a/vlib/term/ui/input.v +++ b/vlib/term/ui/input.v @@ -139,6 +139,7 @@ pub enum EventType { mouse_drag mouse_scroll key_down + key_up resized } diff --git a/vlib/term/ui/input_nix_test.v b/vlib/term/ui/input_nix_test.v index 94aa070d4..1169cb880 100644 --- a/vlib/term/ui/input_nix_test.v +++ b/vlib/term/ui/input_nix_test.v @@ -10,6 +10,16 @@ fn test_escape_sequence_parses_csi_u_plain_key() { assert event.utf8 == seq } +fn test_escape_sequence_parses_csi_u_plain_key_without_modifier_param() { + seq := '\x1b[27u' + event, len := escape_sequence(seq) + assert len == seq.len + assert event.typ == .key_down + assert event.code == .escape + assert event.modifiers.is_empty() + assert event.utf8 == seq +} + fn test_escape_sequence_parses_csi_u_modified_key() { seq := '\x1b[97;5u' event, len := escape_sequence(seq) @@ -22,6 +32,37 @@ fn test_escape_sequence_parses_csi_u_modified_key() { assert event.utf8 == seq } +fn test_escape_sequence_parses_csi_u_key_release() { + seq := '\x1b[97;1:3u' + event, len := escape_sequence(seq) + assert len == seq.len + assert event.typ == .key_up + assert event.code == .a + assert event.modifiers.is_empty() + assert event.utf8 == seq +} + +fn test_escape_sequence_parses_csi_u_key_repeat_with_text() { + seq := '\x1b[97;1:2;97u' + event, len := escape_sequence(seq) + assert len == seq.len + assert event.typ == .key_down + assert event.code == .a + assert event.ascii == `a` + assert event.utf8 == 'a' +} + +fn test_escape_sequence_parses_csi_u_shifted_text_with_associated_text() { + seq := '\x1b[97;2;65u' + event, len := escape_sequence(seq) + assert len == seq.len + assert event.typ == .key_down + assert event.code == .a + assert event.ascii == `A` + assert event.modifiers == .shift + assert event.utf8 == 'A' +} + fn test_escape_sequence_parses_csi_u_enter_key() { seq := '\x1b[13;1u' event, len := escape_sequence(seq) @@ -50,3 +91,13 @@ fn test_escape_sequence_parses_modified_special_key_sequence() { assert event.modifiers == .ctrl assert event.utf8 == seq } + +fn test_escape_sequence_parses_modified_special_key_release_sequence() { + seq := '\x1b[1;5:3A' + event, len := escape_sequence(seq) + assert len == seq.len + assert event.typ == .key_up + assert event.code == .up + assert event.modifiers == .ctrl + assert event.utf8 == seq +} diff --git a/vlib/term/ui/input_windows.c.v b/vlib/term/ui/input_windows.c.v index f518754ba..d827e1a08 100644 --- a/vlib/term/ui/input_windows.c.v +++ b/vlib/term/ui/input_windows.c.v @@ -150,11 +150,6 @@ fn (mut ctx Context) parse_events() { e := unsafe { ctx.read_buf[i].Event.KeyEvent } ch := e.wVirtualKeyCode ascii := unsafe { e.uChar.AsciiChar } - if e.bKeyDown == 0 { - continue - } - // we don't handle key_up events because they don't exist on linux... - // see: https://docs.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes code := match int(ch) { C.VK_BACK { KeyCode.backspace } C.VK_RETURN { KeyCode.enter } @@ -187,16 +182,28 @@ fn (mut ctx Context) parse_events() { modifiers.set(.shift) } - mut event := &Event{ - typ: .key_down - modifiers: modifiers - code: code - ascii: ascii - width: int(e.dwControlKeyState) - height: int(e.wVirtualKeyCode) - utf8: unsafe { e.uChar.UnicodeChar.str() } + event_type := if e.bKeyDown == 0 { + EventType.key_up + } else { + EventType.key_down + } + repeat_count := if event_type == .key_down && e.wRepeatCount > 0 { + int(e.wRepeatCount) + } else { + 1 + } + for _ in 0 .. repeat_count { + mut event := &Event{ + typ: event_type + modifiers: modifiers + code: code + ascii: ascii + width: int(e.dwControlKeyState) + height: int(e.wVirtualKeyCode) + utf8: unsafe { e.uChar.UnicodeChar.str() } + } + ctx.event(event) } - ctx.event(event) } C.MOUSE_EVENT { e := unsafe { ctx.read_buf[i].Event.MouseEvent } diff --git a/vlib/term/ui/termios_nix.c.v b/vlib/term/ui/termios_nix.c.v index 7ae1b6c18..eb8f874f7 100644 --- a/vlib/term/ui/termios_nix.c.v +++ b/vlib/term/ui/termios_nix.c.v @@ -4,6 +4,7 @@ module ui import os +import strings import time import term.termios @@ -16,6 +17,8 @@ pub struct C.winsize { const termios_at_startup = get_termios() +const kitty_keyboard_flags = 0b10 | 0b1000 | 0b10000 + @[inline] fn get_termios() termios.Termios { mut t := termios.Termios{} @@ -116,6 +119,11 @@ fn (mut ctx Context) termios_setup() ! { print('\x1b[2J\x1b[3J\x1b[1;1H') flush_stdout() } + if ctx.cfg.capture_events { + // Ask supporting terminals to report press/repeat/release in CSI-u form. + print('\x1b[>${kitty_keyboard_flags}u') + flush_stdout() + } ctx.window_height, ctx.window_width = get_terminal_size() // Reset console on exit @@ -207,9 +215,13 @@ fn termios_reset() { // C.TCSANOW ?? mut startup := termios_at_startup termios.tcsetattr(C.STDIN_FILENO, C.TCSAFLUSH, mut startup) + c := ctx_ptr + if unsafe { c != 0 } && c.cfg.capture_events { + // Pop the keyboard mode stack before leaving the current screen. + print('\x1b[ 0 { param - 1 } else { 0 } + mut modifiers := unsafe { Modifiers(0) } + if flags & 0b001 != 0 { + modifiers.set(.shift) + } + if flags & 0b010 != 0 { + modifiers.set(.alt) + } + if flags & 0b100 != 0 { + modifiers.set(.ctrl) + } + return modifiers +} + +@[inline] +fn key_event_type_from_report_param(param int) EventType { return match param { - 2 { .shift } - 3 { .alt } - 4 { .shift | .alt } - 5 { .ctrl } - 6 { .ctrl | .shift } - 7 { .ctrl | .alt } - 8 { .ctrl | .alt | .shift } - else { unsafe { Modifiers(0) } } + 3 { .key_up } + else { .key_down } } } @[inline] -fn event_from_reported_key(codepoint int, raw string, modifiers Modifiers) &Event { +fn parse_key_report_param(param string) (Modifiers, EventType) { + if param.len == 0 { + return unsafe { Modifiers(0) }, EventType.key_down + } + parts := param.split(':') + modifiers := modifiers_from_report_param(parts[0].int()) + event_type := if parts.len > 1 { + key_event_type_from_report_param(parts[1].int()) + } else { + EventType.key_down + } + return modifiers, event_type +} + +fn utf8_from_reported_text(param string) string { + if param.len == 0 { + return '' + } + mut builder := strings.new_builder(param.len) + for part in param.split(':') { + codepoint := part.int() + if codepoint <= 0 || codepoint > 0x10ffff { + continue + } + builder.write_string(utf32_to_str(u32(codepoint))) + } + return builder.str() +} + +@[inline] +fn event_from_reported_key(codepoint int, raw string, modifiers Modifiers, event_type EventType, text string) &Event { + mut utf8 := raw + if text.len > 0 { + utf8 = text + } + mut ascii := u8(0) + if text.len == 1 { + ascii = text[0] + } if codepoint <= 0 || codepoint > 0x10ffff { return &Event{ - typ: .key_down - utf8: raw + typ: event_type + ascii: ascii + utf8: utf8 modifiers: modifiers } } @@ -421,25 +482,27 @@ fn event_from_reported_key(codepoint int, raw string, modifiers Modifiers) &Even ch := u8(codepoint) if ch == `\r` { return &Event{ - typ: .key_down + typ: event_type ascii: ch code: .enter - utf8: raw + utf8: utf8 modifiers: modifiers } } base := single_char(ch.ascii_str()) + event_ascii := if ascii != 0 { ascii } else { base.ascii } return &Event{ - typ: base.typ - ascii: base.ascii + typ: event_type + ascii: event_ascii code: base.code - utf8: raw + utf8: utf8 modifiers: base.modifiers | modifiers } } return &Event{ - typ: .key_down - utf8: raw + typ: event_type + ascii: ascii + utf8: utf8 modifiers: modifiers } } @@ -449,11 +512,17 @@ fn parse_csi_u_key_sequence(single string, buf string) &Event { return unsafe { nil } } parts := buf[1..buf.len - 1].split(';') - if parts.len != 2 { + if parts.len < 1 || parts.len > 3 { return unsafe { nil } } - return event_from_reported_key(parts[0].int(), single, - modifiers_from_report_param(parts[1].int())) + codepoint := parts[0].split(':')[0].int() + mut modifiers := unsafe { Modifiers(0) } + mut event_type := EventType.key_down + if parts.len > 1 { + modifiers, event_type = parse_key_report_param(parts[1]) + } + text := if parts.len > 2 { utf8_from_reported_text(parts[2]) } else { '' } + return event_from_reported_key(codepoint, single, modifiers, event_type, text) } fn parse_modify_other_keys_sequence(single string, buf string) &Event { @@ -465,7 +534,7 @@ fn parse_modify_other_keys_sequence(single string, buf string) &Event { return unsafe { nil } } return event_from_reported_key(parts[2].int(), single, - modifiers_from_report_param(parts[1].int())) + modifiers_from_report_param(parts[1].int()), .key_down, '') } @[inline] @@ -508,22 +577,22 @@ fn key_code_from_tilde_param(param string) KeyCode { } } -fn parse_csi_modified_key_sequence(buf string) (KeyCode, Modifiers, bool) { +fn parse_csi_modified_key_sequence(buf string) (KeyCode, Modifiers, EventType, bool) { if buf.len < 5 || buf[0] != `[` { - return KeyCode.null, unsafe { Modifiers(0) }, false + return KeyCode.null, unsafe { Modifiers(0) }, EventType.key_down, false } final := buf[buf.len - 1] params := buf[1..buf.len - 1].split(';') if params.len != 2 { - return KeyCode.null, unsafe { Modifiers(0) }, false + return KeyCode.null, unsafe { Modifiers(0) }, EventType.key_down, false } - modifiers := modifiers_from_report_param(params[1].int()) + modifiers, event_type := parse_key_report_param(params[1]) if final == `~` { code := key_code_from_tilde_param(params[0]) - return code, modifiers, code != .null + return code, modifiers, event_type, code != .null } code := key_code_from_csi_final(final) - return code, modifiers, code != .null + return code, modifiers, event_type, code != .null } fn escape_sequence(buf_ string) (&Event, int) { @@ -646,6 +715,7 @@ fn escape_sequence(buf_ string) (&Event, int) { mut code := KeyCode.null mut modifiers := unsafe { Modifiers(0) } + mut event_type := EventType.key_down match buf { '[A', 'OA' { code = .up } '[B', 'OB' { code = .down } @@ -678,15 +748,16 @@ fn escape_sequence(buf_ string) (&Event, int) { } if code == .null { - parsed_code, parsed_modifiers, ok := parse_csi_modified_key_sequence(buf) + parsed_code, parsed_modifiers, parsed_event_type, ok := parse_csi_modified_key_sequence(buf) if ok { code = parsed_code modifiers = parsed_modifiers + event_type = parsed_event_type } } return &Event{ - typ: .key_down + typ: event_type code: code utf8: single modifiers: modifiers -- 2.39.5