diff --git a/script/setup b/script/setup index fb0cb93..2842a35 100755 --- a/script/setup +++ b/script/setup @@ -1,3 +1,3 @@ #!/bin/sh -echo "Hello from custom script" +direnv allow \ No newline at end of file diff --git a/src/c.zig b/src/c.zig index 046a68b..2f6c898 100644 --- a/src/c.zig +++ b/src/c.zig @@ -96,6 +96,11 @@ pub const SDL_EVENT_DROP_COMPLETE = c_import.SDL_EVENT_DROP_COMPLETE; pub const SDL_EVENT_DROP_POSITION = c_import.SDL_EVENT_DROP_POSITION; pub const SDL_TOUCH_MOUSEID = c_import.SDL_TOUCH_MOUSEID; pub const SDL_BUTTON_LEFT: c_import.Uint8 = c_import.SDL_BUTTON_LEFT; +pub const SDL_BUTTON_MIDDLE: c_import.Uint8 = c_import.SDL_BUTTON_MIDDLE; +pub const SDL_BUTTON_RIGHT: c_import.Uint8 = c_import.SDL_BUTTON_RIGHT; +pub const SDL_BUTTON_LMASK: c_import.SDL_MouseButtonFlags = c_import.SDL_BUTTON_LMASK; +pub const SDL_BUTTON_MMASK: c_import.SDL_MouseButtonFlags = c_import.SDL_BUTTON_MMASK; +pub const SDL_BUTTON_RMASK: c_import.SDL_MouseButtonFlags = c_import.SDL_BUTTON_RMASK; pub const SDL_SYSTEM_CURSOR_DEFAULT = c_import.SDL_SYSTEM_CURSOR_DEFAULT; pub const SDL_SYSTEM_CURSOR_TEXT = c_import.SDL_SYSTEM_CURSOR_TEXT; pub const SDL_SYSTEM_CURSOR_POINTER = c_import.SDL_SYSTEM_CURSOR_POINTER; diff --git a/src/input/mapper.zig b/src/input/mapper.zig index 939e1d5..0260463 100644 --- a/src/input/mapper.zig +++ b/src/input/mapper.zig @@ -578,6 +578,124 @@ test "encodeMouseScroll - scroll down X10 with position" { try std.testing.expectEqual(@as(u8, 5 + 33), buf[5]); // row + 33 } +pub const MouseButton = enum(u8) { left = 0, middle = 1, right = 2 }; + +/// Encodes a mouse button press or release event for terminal mouse tracking. +/// SGR format: CSI < button ; col ; row M (press) or m (release). 1-based coordinates. +/// X10 format: CSI M . Release uses button code 3. +pub fn encodeMouseButton( + button: MouseButton, + col: u16, + row: u16, + press: bool, + sgr_format: bool, + buf: []u8, +) usize { + const btn: u8 = @intFromEnum(button); + + if (sgr_format) { + const suffix: u8 = if (press) 'M' else 'm'; + const result = std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}{c}", .{ btn, col + 1, row + 1, suffix }) catch return 0; + return result.len; + } else { + const x10_btn: u8 = if (press) btn else 3; // 3 = release indicator in X10 + const x10_offset: u16 = 33; + const x10_coord_max: u16 = 255 - x10_offset; + const x = @min(col, x10_coord_max) + x10_offset; + const y = @min(row, x10_coord_max) + x10_offset; + if (buf.len < 6) return 0; + buf[0] = '\x1b'; + buf[1] = '['; + buf[2] = 'M'; + buf[3] = x10_btn + 32; + buf[4] = @intCast(x); + buf[5] = @intCast(y); + return 6; + } +} + +/// Encodes a mouse motion event for terminal mouse tracking. +/// Motion uses button code + 32 (the motion flag). Button code 3 means no button held. +/// SGR format: CSI < code ; col ; row M. X10 format: CSI M . +pub fn encodeMouseMotion( + button: ?MouseButton, + col: u16, + row: u16, + sgr_format: bool, + buf: []u8, +) usize { + const base: u8 = if (button) |b| @intFromEnum(b) else 3; // 3 = no button + const code: u8 = base + 32; // motion flag + + if (sgr_format) { + const result = std.fmt.bufPrint(buf, "\x1b[<{d};{d};{d}M", .{ code, col + 1, row + 1 }) catch return 0; + return result.len; + } else { + const x10_offset: u16 = 33; + const x10_coord_max: u16 = 255 - x10_offset; + const x = @min(col, x10_coord_max) + x10_offset; + const y = @min(row, x10_coord_max) + x10_offset; + if (buf.len < 6) return 0; + buf[0] = '\x1b'; + buf[1] = '['; + buf[2] = 'M'; + buf[3] = code + 32; + buf[4] = @intCast(x); + buf[5] = @intCast(y); + return 6; + } +} + +test "encodeMouseButton - left press SGR" { + var buf: [32]u8 = undefined; + const n = encodeMouseButton(.left, 5, 3, true, true, &buf); + try std.testing.expectEqualSlices(u8, "\x1b[<0;6;4M", buf[0..n]); +} + +test "encodeMouseButton - left release SGR" { + var buf: [32]u8 = undefined; + const n = encodeMouseButton(.left, 5, 3, false, true, &buf); + try std.testing.expectEqualSlices(u8, "\x1b[<0;6;4m", buf[0..n]); +} + +test "encodeMouseButton - right press SGR" { + var buf: [32]u8 = undefined; + const n = encodeMouseButton(.right, 0, 0, true, true, &buf); + try std.testing.expectEqualSlices(u8, "\x1b[<2;1;1M", buf[0..n]); +} + +test "encodeMouseButton - left press X10" { + var buf: [32]u8 = undefined; + const n = encodeMouseButton(.left, 5, 3, true, false, &buf); + try std.testing.expectEqual(@as(usize, 6), n); + try std.testing.expectEqualSlices(u8, "\x1b[M", buf[0..3]); + try std.testing.expectEqual(@as(u8, 0 + 32), buf[3]); + try std.testing.expectEqual(@as(u8, 5 + 33), buf[4]); + try std.testing.expectEqual(@as(u8, 3 + 33), buf[5]); +} + +test "encodeMouseButton - release X10 sends button 3" { + var buf: [32]u8 = undefined; + const n = encodeMouseButton(.left, 5, 3, false, false, &buf); + try std.testing.expectEqual(@as(usize, 6), n); + try std.testing.expectEqualSlices(u8, "\x1b[M", buf[0..3]); + try std.testing.expectEqual(@as(u8, 3 + 32), buf[3]); // release = button 3 + try std.testing.expectEqual(@as(u8, 5 + 33), buf[4]); + try std.testing.expectEqual(@as(u8, 3 + 33), buf[5]); +} + +test "encodeMouseMotion - no button SGR" { + var buf: [32]u8 = undefined; + const n = encodeMouseMotion(null, 10, 5, true, &buf); + try std.testing.expectEqualSlices(u8, "\x1b[<35;11;6M", buf[0..n]); +} + +test "encodeMouseMotion - left button held SGR" { + var buf: [32]u8 = undefined; + const n = encodeMouseMotion(.left, 2, 1, true, &buf); + try std.testing.expectEqualSlices(u8, "\x1b[<32;3;2M", buf[0..n]); +} + test "terminalSwitchShortcut - cmd+1 returns 0" { try std.testing.expectEqual(@as(?usize, 0), terminalSwitchShortcut(c.SDLK_1, c.SDL_KMOD_GUI, 9)); } diff --git a/src/render/renderer.zig b/src/render/renderer.zig index 0c35aec..92f148e 100644 --- a/src/render/renderer.zig +++ b/src/render/renderer.zig @@ -396,6 +396,9 @@ fn renderSessionContent( var run_width_cells: c_int = 0; var run_variant: FontVariant = .regular; + var underline_count: usize = 0; + var underline_segments: [256]struct { x_start: f32, x_end: f32, y_pos: f32, color: c.SDL_Color } = undefined; + var col: usize = 0; while (col < visible_cols) : (col += 1) { const list_cell = pages.getCell(if (view.is_viewing_scrollback) @@ -491,6 +494,16 @@ fn renderSessionContent( } } + if (style.flags.underline != .none and underline_count < underline_segments.len) { + underline_segments[underline_count] = .{ + .x_start = @floatFromInt(x), + .x_end = @floatFromInt(x + eff_cw * glyph_width_cells - 1), + .y_pos = @floatFromInt(y + eff_ch - 1), + .color = fg_color, + }; + underline_count += 1; + } + const is_box_drawing = cp != 0 and cp != ' ' and !style.flags.invisible and renderBoxDrawing(renderer, cp, x, y, eff_cw, eff_ch, fg_color); if (is_box_drawing) { try flushRun(font, run_buf[0..], run_len, run_x, y, run_cells, eff_cw, eff_ch, run_fg, run_variant); @@ -585,6 +598,11 @@ fn renderSessionContent( } try flushRun(font, run_buf[0..], run_len, run_x, origin_y + @as(c_int, @intCast(row)) * cell_height_actual, run_cells, eff_cw, eff_ch, run_fg, run_variant); + + for (underline_segments[0..underline_count]) |seg| { + _ = c.SDL_SetRenderDrawColor(renderer, seg.color.r, seg.color.g, seg.color.b, 255); + _ = c.SDL_RenderLine(renderer, seg.x_start, seg.y_pos, seg.x_end, seg.y_pos); + } } if (session.dead) { diff --git a/src/ui/components/session_interaction.zig b/src/ui/components/session_interaction.zig index c204cce..9554070 100644 --- a/src/ui/components/session_interaction.zig +++ b/src/ui/components/session_interaction.zig @@ -186,36 +186,55 @@ pub const SessionInteractionComponent = struct { return true; } - if (host.view_mode == .Full and event.button.button == c.SDL_BUTTON_LEFT) { + if (host.view_mode == .Full) { const focused_idx = host.focused_session; if (focused_idx >= self.sessions.len) return false; const focused = self.sessions[focused_idx]; const view = &self.views[focused_idx]; if (focused.spawned and focused.terminal != null) { - if (fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |pin| { - const clicks = event.button.clicks; - if (clicks >= 3) { - selectLine(focused, view, pin); - } else if (clicks == 2) { - selectWord(focused, view, pin); - } else { - const mod = c.SDL_GetModState(); - const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; - if (cmd_held) { - if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback)) |uri| { - defer self.allocator.free(uri); - open_url.openUrl(self.allocator, uri) catch |err| { - log.err("failed to open URL: {}", .{err}); + const terminal = &focused.terminal.?; + if (terminalHasMouseTracking(terminal) and !view.is_viewing_scrollback) { + if (sdlToMouseButton(event.button.button)) |btn| { + if (fullViewCellFromMouse(mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |cell| { + const sgr = terminal.modes.get(.mouse_format_sgr); + var buf: [32]u8 = undefined; + const n = input.encodeMouseButton(btn, cell.col, cell.row, true, sgr, &buf); + if (n > 0) { + focused.sendInput(buf[0..n]) catch |err| { + log.warn("failed to send mouse button: {}", .{err}); }; + } + return true; + } + } + } + + if (event.button.button == c.SDL_BUTTON_LEFT) { + if (fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |pin| { + const clicks = event.button.clicks; + if (clicks >= 3) { + selectLine(focused, view, pin); + } else if (clicks == 2) { + selectWord(focused, view, pin); + } else { + const mod = c.SDL_GetModState(); + const cmd_held = (mod & c.SDL_KMOD_GUI) != 0; + if (cmd_held) { + if (getLinkAtPin(self.allocator, &focused.terminal.?, pin, view.is_viewing_scrollback)) |uri| { + defer self.allocator.free(uri); + open_url.openUrl(self.allocator, uri) catch |err| { + log.err("failed to open URL: {}", .{err}); + }; + } else { + beginSelection(focused, view, pin); + } } else { beginSelection(focused, view, pin); } - } else { - beginSelection(focused, view, pin); } + return true; } - return true; } } } @@ -224,11 +243,38 @@ pub const SessionInteractionComponent = struct { if (event.button.button == c.SDL_BUTTON_LEFT and self.finishTerminalScrollbarDrag(host.now_ms)) { return true; } - if (host.view_mode == .Full and event.button.button == c.SDL_BUTTON_LEFT) { + if (host.view_mode == .Full) { const focused_idx = host.focused_session; - if (focused_idx >= self.views.len) return false; - endSelection(&self.views[focused_idx]); - return true; + if (focused_idx >= self.sessions.len) return false; + const focused = self.sessions[focused_idx]; + const view = &self.views[focused_idx]; + + if (focused.spawned and focused.terminal != null) { + const terminal = &focused.terminal.?; + if (terminalHasMouseTracking(terminal) and !view.is_viewing_scrollback) { + if (sdlToMouseButton(event.button.button)) |btn| { + const mouse_x: c_int = @intFromFloat(event.button.x); + const mouse_y: c_int = @intFromFloat(event.button.y); + if (fullViewCellFromMouse(mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |cell| { + const sgr = terminal.modes.get(.mouse_format_sgr); + var buf: [32]u8 = undefined; + const n = input.encodeMouseButton(btn, cell.col, cell.row, false, sgr, &buf); + if (n > 0) { + focused.sendInput(buf[0..n]) catch |err| { + log.warn("failed to send mouse release: {}", .{err}); + }; + } + return true; + } + } + } + } + + if (event.button.button == c.SDL_BUTTON_LEFT) { + if (focused_idx >= self.views.len) return false; + endSelection(&self.views[focused_idx]); + return true; + } } }, c.SDL_EVENT_MOUSE_MOTION => { @@ -244,6 +290,38 @@ pub const SessionInteractionComponent = struct { if (focused_idx < self.sessions.len) { var focused = self.sessions[focused_idx]; const view = &self.views[focused_idx]; + + if (focused.spawned and focused.terminal != null and !view.is_viewing_scrollback) { + const terminal = &focused.terminal.?; + const any_tracking = terminal.modes.get(.mouse_event_any); + const btn_tracking = terminal.modes.get(.mouse_event_button); + const btn_held = (event.motion.state & c.SDL_BUTTON_LMASK) != 0 or + (event.motion.state & c.SDL_BUTTON_MMASK) != 0 or + (event.motion.state & c.SDL_BUTTON_RMASK) != 0; + if (any_tracking or (btn_tracking and btn_held)) { + if (fullViewCellFromMouse(mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale)) |cell| { + const held_btn: ?input.MouseButton = if ((event.motion.state & c.SDL_BUTTON_LMASK) != 0) + .left + else if ((event.motion.state & c.SDL_BUTTON_MMASK) != 0) + .middle + else if ((event.motion.state & c.SDL_BUTTON_RMASK) != 0) + .right + else + null; + const sgr = terminal.modes.get(.mouse_format_sgr); + var buf: [32]u8 = undefined; + const n = input.encodeMouseMotion(held_btn, cell.col, cell.row, sgr, &buf); + if (n > 0) { + focused.sendInput(buf[0..n]) catch |err| { + log.warn("failed to send mouse motion: {}", .{err}); + }; + } + self.updateCursor(desired_cursor); + return true; + } + } + } + const pin = fullViewPinFromMouse(focused, view, mouse_x, mouse_y, host.window_w, host.window_h, self.font, host.term_cols, host.term_rows, host.ui_scale); if (view.selection_dragging) { @@ -574,6 +652,22 @@ pub const SessionInteractionComponent = struct { }; }; +fn terminalHasMouseTracking(terminal: anytype) bool { + return terminal.modes.get(.mouse_event_normal) or + terminal.modes.get(.mouse_event_button) or + terminal.modes.get(.mouse_event_any) or + terminal.modes.get(.mouse_event_x10); +} + +fn sdlToMouseButton(sdl_button: u8) ?input.MouseButton { + return switch (sdl_button) { + c.SDL_BUTTON_LEFT => .left, + c.SDL_BUTTON_MIDDLE => .middle, + c.SDL_BUTTON_RIGHT => .right, + else => null, + }; +} + const CellPosition = struct { col: u16, row: u16 }; fn fullViewCellFromMouse(