Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion script/setup
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/bin/sh

echo "Hello from custom script"
direnv allow
5 changes: 5 additions & 0 deletions src/c.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
118 changes: 118 additions & 0 deletions src/input/mapper.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 <button+32> <col+33> <row+33>. 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 <code+32> <col+33> <row+33>.
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));
}
Expand Down
18 changes: 18 additions & 0 deletions src/render/renderer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down
138 changes: 116 additions & 22 deletions src/ui/components/session_interaction.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
Expand All @@ -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 => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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(
Expand Down