feat(gui): add proper support for syncing modifiers on every event

This commit is contained in:
CJ van den Berg 2026-04-11 14:54:42 +02:00
parent e299638f8b
commit fa7b2e1e0a
Signed by: neurocyte
GPG key ID: 8EB1E1BB660E3FB9
3 changed files with 98 additions and 89 deletions

View file

@ -43,8 +43,8 @@
.lazy = true,
},
.wio = .{
.url = "git+https://github.com/neurocyte/wio?ref=master#6186501162ace8645036e71fdb7ebad76a0dca71",
.hash = "wio-0.0.0-8xHrr8XzBQAYPvWczIC0RU40hP-cVH2HR1zcTVfP7r8l",
.url = "git+https://github.com/neurocyte/wio?ref=master#a5f4ccb81fb6bafa348196747a0051cc65e10db9",
.hash = "wio-0.0.0-8xHrr0MKBgAYo_rlL6A2llfOLjikoWrPdV6Aml3OOBwJ",
.lazy = true,
},
.sokol = .{

View file

@ -23,6 +23,10 @@ const gui_config = @import("gui_config");
const log = std.log.scoped(.wio_app);
const press: u8 = 1;
const repeat: u8 = 2;
const release: u8 = 3;
// Re-export cursor types so renderer.zig (which imports 'app' but not 'gpu')
// can use them without a direct dependency on the gpu module.
pub const CursorInfo = gpu.CursorInfo;
@ -47,6 +51,7 @@ var screen_mutex: std.Thread.Mutex = .{};
var screen_pending: std.atomic.Value(bool) = .init(false);
var screen_snap: ?ScreenSnapshot = null;
var tui_pid: thespian.pid = undefined;
var last_mods: input_translate.Mods = .{};
var font_size_px: u16 = 16;
var font_name_buf: [256]u8 = undefined;
var font_name_len: usize = 0;
@ -481,7 +486,7 @@ fn wioLoop() void {
},
.button_press => |btn| {
held_buttons.press(btn);
const mods = input_translate.Mods.fromButtons(held_buttons);
const mods = syncModifiers();
if (input_translate.mouseButtonId(btn)) |mb_id| {
const cp = pixelToCellPos(mouse_pos);
tui_pid.send(.{
@ -496,15 +501,15 @@ fn wioLoop() void {
} else {
if (input_translate.codepointFromButton(btn, .{})) |base_cp| {
const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp;
sendKey(1, base_cp, shifted_cp orelse base_cp, mods);
sendKey(press, base_cp, shifted_cp orelse base_cp, mods);
} else {
if (input_translate.modifierCodepoint(btn)) |mod_cp|
sendKey(1, mod_cp, mod_cp, mods);
sendKey(press, mod_cp, mod_cp, mods);
}
}
},
.button_repeat => |btn| {
const mods = input_translate.Mods.fromButtons(held_buttons);
const mods = syncModifiers();
if (input_translate.mouseButtonId(btn) == null) {
if (input_translate.codepointFromButton(btn, .{})) |base_cp| {
const shifted_cp = if (mods.shift) input_translate.codepointFromButton(btn, .{ .shift = true }) else base_cp;
@ -514,7 +519,7 @@ fn wioLoop() void {
},
.button_release => |btn| {
held_buttons.release(btn);
const mods = input_translate.Mods.fromButtons(held_buttons);
const mods = syncModifiers();
if (input_translate.mouseButtonId(btn)) |mb_id| {
const cp = pixelToCellPos(mouse_pos);
tui_pid.send(.{
@ -540,8 +545,8 @@ fn wioLoop() void {
// ASCII keys are fully handled by .button_press with correct
// base/shifted codepoints, avoiding double-firing on X11.
if (cp > 0x7f) {
const mods = input_translate.Mods.fromButtons(held_buttons);
sendKey(1, cp, cp, mods);
const mods = syncModifiers();
sendKey(press, cp, cp, mods);
}
},
.mouse => |pos| {
@ -564,23 +569,12 @@ fn wioLoop() void {
tui_pid.send(.{ "RDR", "B", @as(u8, 1), btn_id, cp.col, cp.row, cp.xoff, cp.yoff }) catch {};
},
.focused => {
_ = syncModifiers();
window.enableTextInput(.{});
tui_pid.send(.{"focus_in"}) catch {};
},
.unfocused => {
window.disableTextInput();
// Synthesize release events for any modifier keys still held so the TUI
// doesn't see them as stuck. Release in order so mods reflects reality after
// each release.
for (input_translate.modifier_buttons) |mod_btn| {
if (held_buttons.has(mod_btn)) {
held_buttons.release(mod_btn);
const mods = input_translate.Mods.fromButtons(held_buttons);
if (input_translate.modifierCodepoint(mod_btn)) |mod_cp|
sendKey(3, mod_cp, mod_cp, mods);
}
}
held_buttons = .{};
tui_pid.send(.{"focus_out"}) catch {};
},
else => {
@ -722,3 +716,27 @@ fn sendKey(kind: u8, codepoint: u21, shifted_codepoint: u21, mods: input_transla
@as(u8, @bitCast(mods)),
}) catch {};
}
fn syncModifiers() input_translate.Mods {
const mods = input_translate.fromWioModifiers(wio.getModifiers());
// Synthesize release events for any modifier keys no
// longer held so they don't appear stuck.
if (mods.shift != last_mods.shift) {
last_mods.shift = mods.shift;
sendKey(if (last_mods.shift) press else release, vaxis.Key.left_shift, vaxis.Key.left_shift, last_mods);
}
if (mods.alt != last_mods.alt) {
last_mods.alt = mods.alt;
sendKey(if (last_mods.alt) press else release, vaxis.Key.left_alt, vaxis.Key.left_alt, last_mods);
}
if (mods.ctrl != last_mods.ctrl) {
last_mods.ctrl = mods.ctrl;
sendKey(if (last_mods.ctrl) press else release, vaxis.Key.left_control, vaxis.Key.left_control, last_mods);
}
if (mods.super != last_mods.super) {
last_mods.super = mods.super;
sendKey(if (last_mods.super) press else release, vaxis.Key.left_super, vaxis.Key.left_super, last_mods);
}
last_mods = mods;
return mods;
}

View file

@ -7,29 +7,20 @@ const vaxis = @import("vaxis");
// Modifiers bitmask (matches vaxis.Key.Modifiers packed struct layout used
// by the rest of Flow's input handling).
pub const Mods = packed struct(u8) {
shift: bool = false,
alt: bool = false,
ctrl: bool = false,
super: bool = false,
hyper: bool = false,
meta: bool = false,
_pad: u2 = 0,
pub const Mods = vaxis.Key.Modifiers;
pub fn fromButtons(pressed: ButtonSet) Mods {
return .{
.shift = pressed.has(.left_shift) or pressed.has(.right_shift),
.alt = pressed.has(.left_alt) or pressed.has(.right_alt),
.ctrl = pressed.has(.left_control) or pressed.has(.right_control),
.super = pressed.has(.left_gui) or pressed.has(.right_gui),
};
}
pub fn fromWioModifiers(modifiers: wio.Modifiers) Mods {
return .{
.shift = modifiers.shift,
.alt = modifiers.alt,
.ctrl = modifiers.control,
.super = modifiers.super,
};
}
/// True only when shift is the sole active modifier.
pub fn shiftOnly(self: Mods) bool {
return self.shift and !self.alt and !self.ctrl and !self.super and !self.hyper and !self.meta;
}
};
pub fn isShifted(mods: Mods) bool {
return mods.shift and !mods.alt and !mods.ctrl and !mods.super and !mods.hyper and !mods.meta;
}
// Simple set of currently held buttons (for modifier tracking)
pub const ButtonSet = struct {
@ -60,58 +51,58 @@ pub const KeyEvent = struct {
// Map a wio.Button to the primary codepoint for that key
pub fn codepointFromButton(b: wio.Button, mods: Mods) ?u21 {
return switch (b) {
.a => if (mods.shiftOnly()) 'A' else 'a',
.b => if (mods.shiftOnly()) 'B' else 'b',
.c => if (mods.shiftOnly()) 'C' else 'c',
.d => if (mods.shiftOnly()) 'D' else 'd',
.e => if (mods.shiftOnly()) 'E' else 'e',
.f => if (mods.shiftOnly()) 'F' else 'f',
.g => if (mods.shiftOnly()) 'G' else 'g',
.h => if (mods.shiftOnly()) 'H' else 'h',
.i => if (mods.shiftOnly()) 'I' else 'i',
.j => if (mods.shiftOnly()) 'J' else 'j',
.k => if (mods.shiftOnly()) 'K' else 'k',
.l => if (mods.shiftOnly()) 'L' else 'l',
.m => if (mods.shiftOnly()) 'M' else 'm',
.n => if (mods.shiftOnly()) 'N' else 'n',
.o => if (mods.shiftOnly()) 'O' else 'o',
.p => if (mods.shiftOnly()) 'P' else 'p',
.q => if (mods.shiftOnly()) 'Q' else 'q',
.r => if (mods.shiftOnly()) 'R' else 'r',
.s => if (mods.shiftOnly()) 'S' else 's',
.t => if (mods.shiftOnly()) 'T' else 't',
.u => if (mods.shiftOnly()) 'U' else 'u',
.v => if (mods.shiftOnly()) 'V' else 'v',
.w => if (mods.shiftOnly()) 'W' else 'w',
.x => if (mods.shiftOnly()) 'X' else 'x',
.y => if (mods.shiftOnly()) 'Y' else 'y',
.z => if (mods.shiftOnly()) 'Z' else 'z',
.@"0" => if (mods.shiftOnly()) ')' else '0',
.@"1" => if (mods.shiftOnly()) '!' else '1',
.@"2" => if (mods.shiftOnly()) '@' else '2',
.@"3" => if (mods.shiftOnly()) '#' else '3',
.@"4" => if (mods.shiftOnly()) '$' else '4',
.@"5" => if (mods.shiftOnly()) '%' else '5',
.@"6" => if (mods.shiftOnly()) '^' else '6',
.@"7" => if (mods.shiftOnly()) '&' else '7',
.@"8" => if (mods.shiftOnly()) '*' else '8',
.@"9" => if (mods.shiftOnly()) '(' else '9',
.a => if (isShifted(mods)) 'A' else 'a',
.b => if (isShifted(mods)) 'B' else 'b',
.c => if (isShifted(mods)) 'C' else 'c',
.d => if (isShifted(mods)) 'D' else 'd',
.e => if (isShifted(mods)) 'E' else 'e',
.f => if (isShifted(mods)) 'F' else 'f',
.g => if (isShifted(mods)) 'G' else 'g',
.h => if (isShifted(mods)) 'H' else 'h',
.i => if (isShifted(mods)) 'I' else 'i',
.j => if (isShifted(mods)) 'J' else 'j',
.k => if (isShifted(mods)) 'K' else 'k',
.l => if (isShifted(mods)) 'L' else 'l',
.m => if (isShifted(mods)) 'M' else 'm',
.n => if (isShifted(mods)) 'N' else 'n',
.o => if (isShifted(mods)) 'O' else 'o',
.p => if (isShifted(mods)) 'P' else 'p',
.q => if (isShifted(mods)) 'Q' else 'q',
.r => if (isShifted(mods)) 'R' else 'r',
.s => if (isShifted(mods)) 'S' else 's',
.t => if (isShifted(mods)) 'T' else 't',
.u => if (isShifted(mods)) 'U' else 'u',
.v => if (isShifted(mods)) 'V' else 'v',
.w => if (isShifted(mods)) 'W' else 'w',
.x => if (isShifted(mods)) 'X' else 'x',
.y => if (isShifted(mods)) 'Y' else 'y',
.z => if (isShifted(mods)) 'Z' else 'z',
.@"0" => if (isShifted(mods)) ')' else '0',
.@"1" => if (isShifted(mods)) '!' else '1',
.@"2" => if (isShifted(mods)) '@' else '2',
.@"3" => if (isShifted(mods)) '#' else '3',
.@"4" => if (isShifted(mods)) '$' else '4',
.@"5" => if (isShifted(mods)) '%' else '5',
.@"6" => if (isShifted(mods)) '^' else '6',
.@"7" => if (isShifted(mods)) '&' else '7',
.@"8" => if (isShifted(mods)) '*' else '8',
.@"9" => if (isShifted(mods)) '(' else '9',
.space => vaxis.Key.space,
.enter => vaxis.Key.enter,
.tab => vaxis.Key.tab,
.backspace => vaxis.Key.backspace,
.escape => vaxis.Key.escape,
.minus => if (mods.shiftOnly()) '_' else '-',
.equals => if (mods.shiftOnly()) '+' else '=',
.left_bracket => if (mods.shiftOnly()) '{' else '[',
.right_bracket => if (mods.shiftOnly()) '}' else ']',
.backslash => if (mods.shiftOnly()) '|' else '\\',
.semicolon => if (mods.shiftOnly()) ':' else ';',
.apostrophe => if (mods.shiftOnly()) '"' else '\'',
.grave => if (mods.shiftOnly()) '~' else '`',
.comma => if (mods.shiftOnly()) '<' else ',',
.dot => if (mods.shiftOnly()) '>' else '.',
.slash => if (mods.shiftOnly()) '?' else '/',
.minus => if (isShifted(mods)) '_' else '-',
.equals => if (isShifted(mods)) '+' else '=',
.left_bracket => if (isShifted(mods)) '{' else '[',
.right_bracket => if (isShifted(mods)) '}' else ']',
.backslash => if (isShifted(mods)) '|' else '\\',
.semicolon => if (isShifted(mods)) ':' else ';',
.apostrophe => if (isShifted(mods)) '"' else '\'',
.grave => if (isShifted(mods)) '~' else '`',
.comma => if (isShifted(mods)) '<' else ',',
.dot => if (isShifted(mods)) '>' else '.',
.slash => if (isShifted(mods)) '?' else '/',
// Navigation and function keys: kitty protocol codepoints (vaxis.Key).
.up => vaxis.Key.up,
.down => vaxis.Key.down,