diff options
Diffstat (limited to 'src/terminal.zig')
| -rw-r--r-- | src/terminal.zig | 173 |
1 files changed, 142 insertions, 31 deletions
diff --git a/src/terminal.zig b/src/terminal.zig index 7874ca89..c0341d00 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -124,6 +124,9 @@ pub const State = struct { replay: Replay, reply: [256]u8 = undefined, reply_len: u16 = 0, + /// The grid cell the program last heard the mouse at, so motion is + /// reported once per cell. + mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {}, }; const GColor = ghostty_vt.color; @@ -374,8 +377,36 @@ pub fn bracketedPaste(pane: *const Pane) bool { pub fn reportsMouse(pane: *const Pane) bool { if (comptime !enabled) return false; const state = pane.terminal orelse return false; - const m = &state.vt.modes; - return m.get(.mouse_event_normal) or m.get(.mouse_event_button) or m.get(.mouse_event_any); + return state.vt.flags.mouse_event != .none; +} + +/// A paste as xterm makes one (ghostty's encoder): the control bytes a +/// paste could smuggle a command in by (ESC, so no ESC[201~ can end +/// 2004's brackets early, NUL, ^C and the rest of xterm's list) become +/// spaces, then the text goes inside the brackets when the program set +/// 2004, else with its newlines as Returns. `text` is rewritten in place. +pub fn encodePaste(pane: *const Pane, text: []u8) [3][]const u8 { + if (comptime !enabled) { + for (text) |*ch| if (ch.* == '\n') { + ch.* = '\r'; + }; + return .{ "", text, "" }; + } + return ghostty_vt.input.encodePaste(text, .{ .bracketed = bracketedPaste(pane) }); +} + +/// DECSET 1004: the program wants to hear when it gains and loses focus. +pub fn reportsFocus(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.modes.get(.focus_event); +} + +/// ...every move of it, a button down or not (1003). +pub fn tracksAllMotion(pane: *const Pane) bool { + if (comptime !enabled) return false; + const state = pane.terminal orelse return false; + return state.vt.flags.mouse_event == .any; } /// ...and wants them in SGR (1006) rather than the legacy X10 bytes. @@ -395,43 +426,71 @@ pub fn ownsWheel(pane: *const Pane) bool { (state.vt.screens.active_key == .alternate and state.vt.modes.get(.mouse_alternate_scroll)); } -pub const Wheel = enum(u8) { up = 64, down = 65, left = 66, right = 67 }; +pub const MouseMods = struct { shift: bool = false, alt: bool = false, ctrl: bool = false }; +pub const MouseButton = enum { left, middle, right, wheel_up, wheel_down, wheel_left, wheel_right }; +pub const MouseAction = enum { press, release, motion }; + +/// One mouse event for the program, as xterm reports it (ctlseqs; ghostty's +/// encoder): the tracking mode it set (1000 clicks, 1002 drags, 1003 all +/// motion) decides whether it hears of it, the format it set (1006 SGR, +/// 1015 urxvt, 1005 UTF-8, else X10) how, modifiers as shift+4, meta+8, +/// ctrl+16. `col`,`row` are the 0-based grid cell; a drag that left the +/// grid is reported clamped to its edge, motion only when the cell +/// changed. Empty when the program does not hear of it. +pub fn mouseBytes(pane: *Pane, buf: *[64]u8, action: MouseAction, button: ?MouseButton, mods: MouseMods, col: i32, row: i32, any_pressed: bool) []const u8 { + if (comptime !enabled) return &.{}; + const state = pane.terminal orelse return &.{}; + const input = ghostty_vt.input; + var writer = std.Io.Writer.fixed(buf); + var opts: input.MouseEncodeOptions = .{ + .event = state.vt.flags.mouse_event, + .format = state.vt.flags.mouse_format, + .size = .{ + .screen = .{ .width = state.vt.cols, .height = state.vt.rows }, + .cell = .{ .width = 1, .height = 1 }, + .padding = .{}, + }, + .any_button_pressed = any_pressed, + .last_cell = &state.mouse_cell, + }; + // A release is always reported; the rest only over the grid, or held. + if (action == .release) opts.any_button_pressed = true; + input.encodeMouse(&writer, .{ + .action = switch (action) { + .press => .press, + .release => .release, + .motion => .motion, + }, + .button = if (button) |b| switch (b) { + .left => .left, + .middle => .middle, + .right => .right, + .wheel_up => .four, + .wheel_down => .five, + .wheel_left => .six, + .wheel_right => .seven, + } else null, + .mods = .{ .shift = mods.shift, .alt = mods.alt, .ctrl = mods.ctrl }, + .pos = .{ .x = @as(f32, @floatFromInt(col)) + 0.5, .y = @as(f32, @floatFromInt(row)) + 0.5 }, + }, opts) catch return &.{}; + return writer.buffered(); +} /// The bytes a wheel turn at grid cell `col`,`row` (0-based) sends the -/// program, as xterm sends them: its mouse report in the format it asked -/// for, else, on the alternate screen, an arrow key (none for a sideways -/// turn). Null when the wheel is not the program's (`ownsWheel`) or the -/// cell cannot be said in the legacy format. -pub fn wheelBytes(pane: *const Pane, buf: *[32]u8, wheel: Wheel, col: u16, row: u16, ctrl: bool) ?[]const u8 { +/// program: its mouse report while it tracks the mouse, else, on the +/// alternate screen, an arrow key in the cursor keys' mode (none for a +/// sideways turn). Null when the wheel is not the program's (`ownsWheel`). +pub fn wheelBytes(pane: *Pane, buf: *[64]u8, wheel: MouseButton, col: u16, row: u16, mods: MouseMods) ?[]const u8 { if (comptime !enabled) return null; const state = pane.terminal orelse return null; if (!ownsWheel(pane)) return null; const m = &state.vt.modes; if (!reportsMouse(pane)) return switch (wheel) { - .up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A", - .down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B", - .left, .right => null, + .wheel_up => if (m.get(.cursor_keys)) "\x1bOA" else "\x1b[A", + .wheel_down => if (m.get(.cursor_keys)) "\x1bOB" else "\x1b[B", + else => null, }; - const code = @intFromEnum(wheel) + @as(u8, if (ctrl) 16 else 0); - const x = @as(u32, col) + 1; - const y = @as(u32, row) + 1; - if (m.get(.mouse_format_sgr)) return std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, x, y }) catch null; - if (m.get(.mouse_format_urxvt)) return std.fmt.bufPrint(buf, "\x1b[{d};{d};{d}M", .{ 32 + @as(u32, code), x, y }) catch null; - buf[0..3].* = "\x1b[M".*; - buf[3] = 32 + code; - var n: usize = 4; - for ([_]u32{ x, y }) |v| { - const c = 32 + v; - if (m.get(.mouse_format_utf8)) { - if (c > 2047) return null; - n += std.unicode.utf8Encode(@intCast(c), buf[n..]) catch return null; - } else { - if (c > 255) return null; - buf[n] = @intCast(c); - n += 1; - } - } - return buf[0..n]; + return mouseBytes(pane, buf, .press, wheel, mods, col, row, false); } /// The whole scrollback as plain text, `gpa`-owned: what a terminal's body @@ -2706,3 +2765,55 @@ const DeviceAttrs = @typeInfo(@typeInfo(@typeInfo( pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { return .{}; } + +/// Every byte the core wrote to each pane's pty since the last call, `\n` +/// between panes' shares: "0:<bytes>\n1:<bytes>\n" for panes that got any. +fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 { + out.clearRetainingCapacity(); + var per: [pardes.MAX_PANES]std.ArrayList(u8) = @splat(.empty); + defer for (&per) |*b| b.deinit(p.gpa); + while (p.nextEffect()) |effect| { + if (effect == .write) try per[effect.write.pane].appendSlice(p.gpa, effect.write.bytes.slice()); + } + for (per, 0..) |b, id| if (b.items.len > 0) try out.print(p.gpa, "{d}:{s}\n", .{ id, b.items }); + return out.items; +} + +test "a program that asked for focus reports hears CSI I and CSI O as its pane takes and gives up the keyboard, and as the window does" { + if (comptime !enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 90, .rows = 30, .shells = 3 }); + defer p.deinit(); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + _ = try p.render(frame.allocator()); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + _ = try ptyWritesByPane(p, &out); + p.active = 0; + // Asking while its pane has the keyboard: told at once that it has. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[?1004h" } }); + try std.testing.expectEqualStrings("0:\x1b[I\n", try ptyWritesByPane(p, &out)); + p.update(.{ .output = .{ .pane = 1, .bytes = "\x1b[?1004h" } }); + try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out)); + // A click into the other pane moves the keyboard: out of one, into the + // other. + const r = p.rects[1]; + const at_col = r.x + config.GUTTER + 2; + const at_row = p.bodyTop(p.panes[1].?, r) + 1; + p.update(.{ .mouse = .{ .button = .left, .kind = .press, .col = at_col, .row = at_row } }); + p.update(.{ .mouse = .{ .button = .left, .kind = .release, .col = at_col, .row = at_row } }); + try std.testing.expectEqual(@as(usize, 1), p.active); + try std.testing.expectEqualStrings("0:\x1b[O\n1:\x1b[I\n", try ptyWritesByPane(p, &out)); + // The window: away and back. + p.update(.{ .focus = false }); + try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out)); + p.update(.{ .focus = false }); + try std.testing.expectEqualStrings("", try ptyWritesByPane(p, &out)); + p.update(.{ .focus = true }); + try std.testing.expectEqualStrings("1:\x1b[I\n", try ptyWritesByPane(p, &out)); + // A program that did not ask hears nothing: pane 2. + p.active = 2; + p.update(.tick); + try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out)); +} |
