From 50d419a764542d276f323e8c6cb32970755e2663 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Tue, 8 Sep 2026 07:57:31 -0400 Subject: [PATCH 1/2] feat: add topbar new-tab control --- src/app/command_dispatcher.zig | 4 +- src/app/input.zig | 3 +- src/app/lua_callbacks.zig | 4 +- src/app/session_controller.zig | 4 +- src/lua/hollow/actions.lua | 8 +- src/lua/hollow/events.lua | 12 ++ src/lua/hollow/ui/primitives.lua | 1 + .../hollow/ui/widgets/bars/bar_serialize.lua | 38 +++++- src/lua/hollow/ui/widgets/bars/topbar.lua | 35 +++++ src/lua/tests/test_ui_bars.lua | 68 ++++++++++ src/lua_bridge.zig | 24 +++- src/mux/mux.zig | 4 +- src/mux/tests.zig | 26 ++++ src/mux/workspace.zig | 9 +- src/render/sokol_runtime.zig | 120 ++++++++++++++++-- types/hollow.lua | 28 ++-- 16 files changed, 344 insertions(+), 44 deletions(-) diff --git a/src/app/command_dispatcher.zig b/src/app/command_dispatcher.zig index 3ec10ed..c680d73 100644 --- a/src/app/command_dispatcher.zig +++ b/src/app/command_dispatcher.zig @@ -741,7 +741,7 @@ fn execWorkspaceRename(self: *App, request: command_mod.Request) command_mod.Res } fn execTabNew(self: *App, request: command_mod.Request) command_mod.Response { - mux_ops.newTab(self, request.cwd, request.domain, request.cmd, LUA_NOREF); + mux_ops.newTab(self, request.cwd, request.domain, request.cmd, LUA_NOREF, false); return okNull(); } @@ -951,7 +951,7 @@ fn execConfigTheme(self: *App, request: command_mod.Request) command_mod.Respons } fn execRun(self: *App, request: command_mod.Request) command_mod.Response { - mux_ops.newTab(self, request.cwd, request.domain, request.cmd, LUA_NOREF); + mux_ops.newTab(self, request.cwd, request.domain, request.cmd, LUA_NOREF, false); return okNull(); } diff --git a/src/app/input.zig b/src/app/input.zig index 4215337..5f05aa9 100644 --- a/src/app/input.zig +++ b/src/app/input.zig @@ -67,6 +67,7 @@ pub const PendingInputEvent = union(enum) { domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int, + insert_at_end: bool = false, }, close_tab, close_pane, @@ -303,7 +304,7 @@ pub fn processInputQueue(self: *App) void { mux_ops.closeTabAt(self, idx); }, .new_tab => |payload| { - mux_ops.newTab(self, payload.cwd, payload.domain_name, payload.command, payload.callback_ref); + mux_ops.newTab(self, payload.cwd, payload.domain_name, payload.command, payload.callback_ref, payload.insert_at_end); }, .close_tab => { mux_ops.closeTab( diff --git a/src/app/lua_callbacks.zig b/src/app/lua_callbacks.zig index f82af9c..4c06923 100644 --- a/src/app/lua_callbacks.zig +++ b/src/app/lua_callbacks.zig @@ -73,12 +73,12 @@ pub fn luaGetPaneForegroundProcessCallback(app_ptr: *anyopaque, pane_id: usize, return app.getPaneForegroundProcess(pane_id, out_buf); } -pub fn luaNewTabCallback(app_ptr: *anyopaque, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int) bool { +pub fn luaNewTabCallback(app_ptr: *anyopaque, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int, insert_at_end: bool) bool { const app: *App = @ptrCast(@alignCast(app_ptr)); const owned_cwd = if (cwd) |value| app.allocator.dupe(u8, value) catch null else null; const owned_domain = if (domain_name) |name| app.allocator.dupe(u8, name) catch null else null; const owned_command = if (command) |value| app.allocator.dupe(u8, value) catch null else null; - var event: input.PendingInputEvent = .{ .new_tab = .{ .cwd = owned_cwd, .domain_name = owned_domain, .command = owned_command, .callback_ref = callback_ref } }; + var event: input.PendingInputEvent = .{ .new_tab = .{ .cwd = owned_cwd, .domain_name = owned_domain, .command = owned_command, .callback_ref = callback_ref, .insert_at_end = insert_at_end } }; const queued = app.enqueueMouse(event); if (!queued) input.deinitPendingInputEvent(app.allocator, &event); return queued; diff --git a/src/app/session_controller.zig b/src/app/session_controller.zig index 942bb71..b09a552 100644 --- a/src/app/session_controller.zig +++ b/src/app/session_controller.zig @@ -161,7 +161,7 @@ pub fn scrollActiveViewportBottom(self: *App) void { // Tab operations // ============================================================ -pub fn newTab(self: *App, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int) void { +pub fn newTab(self: *App, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int, insert_at_end: bool) void { const start_ms = io.milliTimestamp(); var mux = if (self.mux) |*value| value else return; const runtime = if (self.ghostty) |*value| value else return; @@ -172,7 +172,7 @@ pub fn newTab(self: *App, cwd: ?[]const u8, domain_name: ?[]const u8, command: ? .{ .command = value } else null; - mux.newTab(runtime, cbs, self.config, self.cell_width_px, self.cell_height_px, self.config.window_width, self.config.window_height, cwd, domain_name, launch_command) catch |err| { + mux.newTab(runtime, cbs, self.config, self.cell_width_px, self.cell_height_px, self.config.window_width, self.config.window_height, cwd, domain_name, launch_command, insert_at_end) catch |err| { std.log.err("app: newTab failed: {s}", .{@errorName(err)}); if (self.lua) |*lua| lua.invokeOperationCallback(callback_ref, false, .none); return; diff --git a/src/lua/hollow/actions.lua b/src/lua/hollow/actions.lua index 81a558c..e2b1d8f 100644 --- a/src/lua/hollow/actions.lua +++ b/src/lua/hollow/actions.lua @@ -233,8 +233,8 @@ function M.setup(hollow, host_api) -- ── Tab Actions ────────────────────────────────── register(hollow, "new_tab", { - run = function() - host_api.new_tab({}) + run = function(opts) + host_api.new_tab(opts or {}) end, desc = "Create new tab", category = "tab", @@ -738,9 +738,9 @@ function M.setup(hollow, host_api) }) register(hollow, "new_tab_in_domain", { - run = function() + run = function(opts) pick_domain_and_run(function(item) - host_api.new_tab({ domain = item.domain_name }) + host_api.new_tab({ domain = item.domain_name, insert_at_end = opts and opts.insert_at_end }) end) end, desc = "Create new tab with a domain", diff --git a/src/lua/hollow/events.lua b/src/lua/hollow/events.lua index 88ade09..5109924 100644 --- a/src/lua/hollow/events.lua +++ b/src/lua/hollow/events.lua @@ -111,6 +111,14 @@ function M.setup(hollow, state, term_helpers) } end + local function adapt_bar_node(payload) + return { + id = payload.id, + mods = hollow.keymap.format_mods(payload.mods), + shifted = payload.shifted == true, + } + end + local adapters = { ["term:tab_activated"] = adapt_tab_activated, ["workspace:new"] = adapt_workspace_new, @@ -127,6 +135,10 @@ function M.setup(hollow, state, term_helpers) ["copy_mode:changed"] = adapt_copy_mode_changed, ["key:unhandled"] = adapt_key_unhandled, ["window:files_dropped"] = adapt_files_dropped, + ["topbar:hover"] = adapt_bar_node, + ["topbar:click"] = adapt_bar_node, + ["bottombar:hover"] = adapt_bar_node, + ["bottombar:click"] = adapt_bar_node, } local function adapt_builtin_payload(name, payload) diff --git a/src/lua/hollow/ui/primitives.lua b/src/lua/hollow/ui/primitives.lua index 6275808..ca21719 100644 --- a/src/lua/hollow/ui/primitives.lua +++ b/src/lua/hollow/ui/primitives.lua @@ -201,6 +201,7 @@ function bar.custom(opts) return { _type = "bar_custom", id = opts.id, + style = opts.style, render = opts.render, cache_ttl_ms = opts.cache_ttl_ms, on_click = opts.on_click, diff --git a/src/lua/hollow/ui/widgets/bars/bar_serialize.lua b/src/lua/hollow/ui/widgets/bars/bar_serialize.lua index b783507..8fa8c76 100644 --- a/src/lua/hollow/ui/widgets/bars/bar_serialize.lua +++ b/src/lua/hollow/ui/widgets/bars/bar_serialize.lua @@ -288,11 +288,45 @@ local function clamp_tab_segment_width(segment, width) return segment end - local text = truncate_text_end(segment.text or "", width) - if text == segment.text then + if util.utf8_len(segment.text or "") <= width then return segment end + local segments = segment.segments + if type(segments) == "table" and #segments > 1 then + local suffix_index + for index = #segments, 1, -1 do + if type(segments[index].id) == "string" and segments[index].id ~= "" then + suffix_index = index + break + end + end + + if suffix_index ~= nil then + local suffix_width = 0 + for index = suffix_index, #segments do + suffix_width = suffix_width + util.utf8_len(segments[index].text or "") + end + + if suffix_width <= width then + local prefix = {} + for index = 1, suffix_index - 1 do + prefix[#prefix + 1] = segments[index] + end + + local clamped = util.clone_value(segment) + clamped.segments = truncate_segments_end(prefix, width - suffix_width) or {} + for index = suffix_index, #segments do + clamped.segments[#clamped.segments + 1] = util.clone_value(segments[index]) + end + clamped.text = shared.segments_plain_text(clamped.segments) + return clamped + end + end + end + + local text = truncate_text_end(segment.text or "", width) + local clamped = util.clone_value(segment) clamped.text = text clamped.segments = truncate_segments_end(segment.segments, width) diff --git a/src/lua/hollow/ui/widgets/bars/topbar.lua b/src/lua/hollow/ui/widgets/bars/topbar.lua index dd3f15b..6f4aa2c 100644 --- a/src/lua/hollow/ui/widgets/bars/topbar.lua +++ b/src/lua/hollow/ui/widgets/bars/topbar.lua @@ -107,6 +107,38 @@ local function configured_topbar_bar_opts(value) return type(value) == "table" and value or {} end +local function configured_topbar_new_tab(value) + if value == false then + return nil + end + + local options = type(value) == "table" and value or {} + local theme = shared.resolve_theme().ui + local id = options.id or "new-tab-button" + local style = M.merge_tables({ + fg = theme.widgets.all.title, + padding = { left = 5, right = 5, top = 1, bottom = 2 }, + margin = { left = 1 }, + hover = { fg = theme.accent }, + }, options.style) + style.id = id + + return ui.bar.custom({ + id = id, + style = style, + render = function() + return options.text or "+" + end, + on_click = function(event) + if event and event.shifted then + hollow.action.new_tab_in_domain({ insert_at_end = true }) + else + hollow.action.new_tab({ insert_at_end = true }) + end + end, + }) +end + local function configured_topbar_time(value) if value == false then return false @@ -137,13 +169,16 @@ function M.widget() render = function(ctx) local workspace = configured_topbar_bar_opts(opts.workspace) local tabs = configured_topbar_bar_opts(opts.tabs) + local new_tab = configured_topbar_new_tab(opts.new_tab) local separator = configured_topbar_separator(opts.separator) local cwd = configured_topbar_cwd(ctx, opts.cwd) local key_legend = configured_topbar_bar_opts(opts.key_legend) local items = tbl({ workspace ~= false and ui.bar.workspace(workspace), separator ~= nil and workspace ~= false and tabs ~= false and separator, + tabs ~= false and tabs.fit ~= "content" and new_tab or false, tabs ~= false and ui.bar.tabs(tabs), + (tabs == false or tabs.fit == "content") and new_tab or false, }) :filter(function(item) return item ~= false diff --git a/src/lua/tests/test_ui_bars.lua b/src/lua/tests/test_ui_bars.lua index 90c8998..f407172 100644 --- a/src/lua/tests/test_ui_bars.lua +++ b/src/lua/tests/test_ui_bars.lua @@ -56,6 +56,41 @@ describe("UI bars test suite", function() "configured topbar should serialize tabs content" ) end) + + it("creates a tab from the topbar button and opens domain picker when shifted", function() + hollow.config.set({ + domains = { + main = { shell = "sh" }, + dev = { shell = "sh" }, + }, + }) + hollow.ui.topbar.configure({ + workspace = false, + separator = false, + tabs = false, + cwd = false, + key_legend = false, + time = false, + }) + + local topbar = hollow.ui._topbar_state() + harness.assert_equal( + topbar.items[1].id, + "new-tab-button", + "configured topbar should include new-tab button" + ) + + hollow._emit_builtin_event("topbar:click", { id = "new-tab-button", shifted = false }) + harness.assert_equal(recorded.new_tab_calls, 1, "plain new-tab click should create a tab") + harness.assert_true(recorded.new_tab.insert_at_end, "topbar new-tab click should append tab") + + hollow._emit_builtin_event("topbar:click", { id = "new-tab-button", shifted = true }) + harness.assert_true( + hollow.ui.overlay.depth() > 0, + "shifted new-tab click should open domain picker" + ) + hollow.ui.overlay.clear() + end) end) describe("tabs max_width", function() @@ -115,6 +150,39 @@ describe("UI bars test suite", function() "tabs max_width should truncate serialized formatted segments" ) end) + + it("preserves a clickable close suffix while truncating a title", function() + hollow.ui.topbar.configure({ + new_tab = false, + workspace = false, + separator = false, + cwd = false, + key_legend = false, + time = false, + tabs = { + fit = "content", + max_width = 20, + format = function(tab) + return { + hollow.ui.span(tab.title), + hollow.ui.span(" ×", { on_click = function() end }), + } + end, + }, + }) + _G.host_api.set_tab_title_by_id(201, "this is a very looooong title") + local tab = hollow.ui._topbar_state().items[1].tabs[1] + harness.assert_equal( + tab.text, + "this is a very ... ×", + "tab truncation should retain close control text" + ) + harness.assert_equal( + tab.segments[#tab.segments].text, + " ×", + "tab truncation should retain close control segment" + ) + end) end) describe("topbar mounting", function() diff --git a/src/lua_bridge.zig b/src/lua_bridge.zig index 7468b58..fada6cd 100644 --- a/src/lua_bridge.zig +++ b/src/lua_bridge.zig @@ -188,7 +188,7 @@ pub const AppCallbacks = struct { set_floating_pane_bounds: *const fn (app: *anyopaque, pane_id: usize, x: f32, y: f32, width: f32, height: f32) void, set_pane_foreground_process: *const fn (app: *anyopaque, pane_id: usize, process: []const u8) void, move_pane: *const fn (app: *anyopaque, pane_id: usize, direction: []const u8, amount: f32) void, - new_tab: *const fn (app: *anyopaque, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int) bool, + new_tab: *const fn (app: *anyopaque, cwd: ?[]const u8, domain_name: ?[]const u8, command: ?[]const u8, callback_ref: c_int, insert_at_end: bool) bool, close_tab: *const fn (app: *anyopaque) void, close_pane: *const fn (app: *anyopaque) void, next_tab: *const fn (app: *anyopaque) void, @@ -589,9 +589,13 @@ pub const BuiltInPayload = union(enum) { }, topbar_node: struct { id: []const u8, + mods: u32 = 0, + shifted: bool = false, }, bottombar_node: struct { id: []const u8, + mods: u32 = 0, + shifted: bool = false, }, overlay_node: struct { id: []const u8, @@ -2579,14 +2583,22 @@ fn pushBuiltInPayload(allocator: std.mem.Allocator, api: Api, state: *State, pay try pushFileDropPayload(allocator, api, state, value.paths, value.pane_id, value.x, value.y, value.text); }, .topbar_node => |value| { - api.create_table(state, 0, 1); + api.create_table(state, 0, 3); try pushOwnedString(allocator, api, state, value.id); api.set_field(state, -2, "id"); + api.push_number(state, @floatFromInt(value.mods)); + api.set_field(state, -2, "mods"); + api.push_boolean(state, if (value.shifted) 1 else 0); + api.set_field(state, -2, "shifted"); }, .bottombar_node => |value| { - api.create_table(state, 0, 1); + api.create_table(state, 0, 3); try pushOwnedString(allocator, api, state, value.id); api.set_field(state, -2, "id"); + api.push_number(state, @floatFromInt(value.mods)); + api.set_field(state, -2, "mods"); + api.push_boolean(state, if (value.shifted) 1 else 0); + api.set_field(state, -2, "shifted"); }, .overlay_node => |value| { api.create_table(state, 0, 2); @@ -4930,6 +4942,7 @@ fn l_new_tab(state: *State) callconv(.c) c_int { var domain_name: ?[]const u8 = null; var command: ?[]const u8 = null; var callback_ref: c_int = LUA_NOREF; + var insert_at_end = false; switch (@as(LuaType, @enumFromInt(api.value_type(state, 1)))) { .string => { @@ -4942,11 +4955,14 @@ fn l_new_tab(state: *State) callconv(.c) c_int { domain_name = luaStringField(api, state, opts_idx, "domain"); command = luaStringField(api, state, opts_idx, "command"); callback_ref = luaFunctionFieldRef(api, state, opts_idx, "on_complete"); + api.get_field(state, opts_idx, "insert_at_end"); + insert_at_end = api.to_boolean(state, -1) != 0; + pop(api, state, 1); }, else => {}, } - const queued = if (ctx.app_callbacks) |cbs| cbs.new_tab(cbs.app, cwd, domain_name, command, callback_ref) else false; + const queued = if (ctx.app_callbacks) |cbs| cbs.new_tab(cbs.app, cwd, domain_name, command, callback_ref, insert_at_end) else false; if (!queued and callback_ref != LUA_NOREF) api.unref(state, LUA_REGISTRYINDEX, callback_ref); return 0; } diff --git a/src/mux/mux.zig b/src/mux/mux.zig index 74976ad..5ecbfc1 100644 --- a/src/mux/mux.zig +++ b/src/mux/mux.zig @@ -293,7 +293,7 @@ pub const Mux = struct { /// Split the active pane, spawning a new pane in the given direction. /// The new pane becomes the active pane. - pub fn newTab(self: *Mux, runtime: *GhosttyRuntime, callbacks: TerminalCallbacks, cfg: Config, cell_width_px: u32, cell_height_px: u32, window_width: u32, window_height: u32, cwd: ?[]const u8, domain_name: ?[]const u8, launch_command: ?LaunchCommand) !void { + pub fn newTab(self: *Mux, runtime: *GhosttyRuntime, callbacks: TerminalCallbacks, cfg: Config, cell_width_px: u32, cell_height_px: u32, window_width: u32, window_height: u32, cwd: ?[]const u8, domain_name: ?[]const u8, launch_command: ?LaunchCommand, insert_at_end: bool) !void { const ws = self.activeWorkspace() orelse return error.NoActiveWorkspace; const current_pane = self.activePane(); const resolved_domain = domain_name orelse cfg.defaultDomainName(); @@ -306,7 +306,7 @@ pub const Mux = struct { else null; const previous_active = ws.active_tab; - const tab = try ws.newTab(self.allocId()); + const tab = try ws.newTab(self.allocId(), insert_at_end); errdefer { var remove_idx: ?usize = null; for (ws.tabs.items, 0..) |t, i| { diff --git a/src/mux/tests.zig b/src/mux/tests.zig index bc82deb..512f90e 100644 --- a/src/mux/tests.zig +++ b/src/mux/tests.zig @@ -12,6 +12,32 @@ const Tab = tab_mod.Tab; const Workspace = workspace_mod.Workspace; const Mux = mux_mod.Mux; +test "workspace new tab can append after active tab" { + const allocator = std.testing.allocator; + var workspace = Workspace.init(allocator, 1); + defer workspace.tabs.deinit(allocator); + + const first = try allocator.create(Tab); + defer allocator.destroy(first); + first.* = Tab.init(allocator, 2); + + const second = try allocator.create(Tab); + defer allocator.destroy(second); + second.* = Tab.init(allocator, 3); + + try workspace.appendTab(first); + try workspace.appendTab(second); + workspace.active_tab = first; + + const appended = try workspace.newTab(4, true); + defer allocator.destroy(appended); + + try std.testing.expect(workspace.tabs.items[0] == first); + try std.testing.expect(workspace.tabs.items[1] == second); + try std.testing.expect(workspace.tabs.items[2] == appended); + try std.testing.expect(workspace.active_tab == appended); +} + test "pane focus follows nearest matching split subtree" { const allocator = std.testing.allocator; diff --git a/src/mux/workspace.zig b/src/mux/workspace.zig index d728ec3..05c5517 100644 --- a/src/mux/workspace.zig +++ b/src/mux/workspace.zig @@ -86,11 +86,16 @@ pub const Workspace = struct { self.active_tab = tab; } - pub fn newTab(self: *Workspace, id: usize) !*Tab { + pub fn newTab(self: *Workspace, id: usize, insert_at_end: bool) !*Tab { const tab = try self.allocator.create(Tab); errdefer self.allocator.destroy(tab); tab.* = Tab.init(self.allocator, id); - const insert_at = if (self.active_tab) |_| self.activeTabIndex() + 1 else self.tabs.items.len; + const insert_at = if (insert_at_end) + self.tabs.items.len + else if (self.active_tab) |_| + self.activeTabIndex() + 1 + else + self.tabs.items.len; try self.tabs.insert(self.allocator, insert_at, tab); self.active_tab = tab; return tab; diff --git a/src/render/sokol_runtime.zig b/src/render/sokol_runtime.zig index 7338b3e..8b4d350 100644 --- a/src/render/sokol_runtime.zig +++ b/src/render/sokol_runtime.zig @@ -1489,6 +1489,94 @@ fn tabViewShowsFullSegments(view: *const BarTabView, display: []const u8) bool { return view.segments_len > 0 and countCodepoints(display) == tabViewCodepoints(view) and tabViewTextLen(view) == view.segment.text.len; } +fn tabViewSuffixIndex(view: *const BarTabView) ?usize { + var index = view.segments_len; + while (index > 0) { + index -= 1; + if (view.segments[index].id != null) return index; + } + return null; +} + +fn tabViewPrefixText(view: *const BarTabView, suffix_index: usize, out: []u8) []const u8 { + var used: usize = 0; + for (view.segments[0..suffix_index]) |segment| { + if (used >= out.len) break; + const copy_len = @min(segment.text.len, out.len - used); + fastmem.copy(u8, out[used .. used + copy_len], segment.text[0..copy_len]); + used += copy_len; + if (copy_len < segment.text.len) break; + } + return out[0..used]; +} + +fn drawFittedTabSegments(renderer: *FtRenderer, x: f32, y: f32, max_width: f32, view: *const BarTabView, default_fg: ghostty.ColorRgb) void { + const max_chars: usize = if (max_width > 0) + @max(1, @as(usize, @intFromFloat(max_width / renderer.cell_w))) + else + 0; + const suffix_index = tabViewSuffixIndex(view) orelse return; + var suffix_chars: usize = 0; + for (view.segments[suffix_index..view.segments_len]) |segment| suffix_chars += countCodepoints(segment.text); + if (max_chars == 0 or suffix_chars > max_chars) { + drawSegmentArray(renderer, x, y, max_width, view.segments[0..view.segments_len], default_fg); + return; + } + + var prefix_buf: [1024]u8 = undefined; + var display_buf: [1024]u8 = undefined; + const prefix = tabViewPrefixText(view, suffix_index, prefix_buf[0..]); + const display_prefix = fitTabLabel(prefix, max_chars - suffix_chars, display_buf[0..]); + var cursor_x = x; + if (display_prefix.len > 0 and suffix_index > 0) { + const prefix_style = view.segments[0]; + const fg = prefix_style.fg orelse default_fg; + renderer.drawLabelFace(cursor_x, y, display_prefix, fg.r, fg.g, fg.b, if (prefix_style.bold) 1 else 0); + c.sgl_load_default_pipeline(); + cursor_x += @as(f32, @floatFromInt(countCodepoints(display_prefix))) * renderer.cell_w; + } + + for (view.segments[suffix_index..view.segments_len]) |segment| { + if (segment.text.len == 0) continue; + const segment_width = @as(f32, @floatFromInt(countCodepoints(segment.text))) * renderer.cell_w; + if (cursor_x + segment_width > x + max_width) break; + const fg = segment.fg orelse default_fg; + renderer.drawLabelFace(cursor_x, y, segment.text, fg.r, fg.g, fg.b, if (segment.bold) 1 else 0); + c.sgl_load_default_pipeline(); + cursor_x += segment_width; + } +} + +fn cacheFittedTabSegments(cache: *BarCache, renderer: *FtRenderer, x: f32, max_width: f32, view: *const BarTabView, tab_index: usize) void { + const max_chars: usize = if (max_width > 0) + @max(1, @as(usize, @intFromFloat(max_width / renderer.cell_w))) + else + 0; + const suffix_index = tabViewSuffixIndex(view) orelse return; + var suffix_chars: usize = 0; + for (view.segments[suffix_index..view.segments_len]) |segment| suffix_chars += countCodepoints(segment.text); + if (max_chars == 0 or suffix_chars > max_chars) return; + + var prefix_buf: [1024]u8 = undefined; + var display_buf: [1024]u8 = undefined; + const prefix = tabViewPrefixText(view, suffix_index, prefix_buf[0..]); + const display_prefix = fitTabLabel(prefix, max_chars - suffix_chars, display_buf[0..]); + const prefix_width = @as(f32, @floatFromInt(countCodepoints(display_prefix))) * renderer.cell_w; + if (prefix_width > 0) { + const prefix_id = if (suffix_index > 0) view.segments[suffix_index - 1].id else null; + cacheBarHitRegion(cache, x, prefix_width, null, 0.0, tab_index, prefix_id); + } + + var cursor_x = x + prefix_width; + for (view.segments[suffix_index..view.segments_len]) |segment| { + if (segment.text.len == 0) continue; + const segment_width = @as(f32, @floatFromInt(countCodepoints(segment.text))) * renderer.cell_w; + if (cursor_x + segment_width > x + max_width) break; + cacheBarHitRegion(cache, cursor_x, segment_width, null, 0.0, tab_index, segment.id); + cursor_x += segment_width; + } +} + fn segmentViewFullWidth(renderer: *FtRenderer, view: *const BarSegmentView) f32 { return view.style.margin.horizontal() + view.style.padding.horizontal() + @as(f32, @floatFromInt(segmentViewCodepoints(view))) * renderer.cell_w; } @@ -1653,7 +1741,10 @@ fn renderBarWidgetSurface(surface: BarSurface, renderer: *FtRenderer, app: *App, const display_title = if (max_label_chars == 0) "" else fitTabLabel(tab.segment.text, max_label_chars, display_buf[0..]); const text_y = bg_y + tab.style.padding.top; const fg = bg_style.fg orelse default_fg; - if (tabViewShowsFullSegments(tab, display_title)) { + if (tab.segments_len > 0 and tabViewCodepoints(tab) > max_label_chars and tabViewSuffixIndex(tab) != null) { + cacheFittedTabSegments(cache, renderer, label_x, label_space, tab, ti); + drawFittedTabSegments(renderer, label_x, text_y, label_space, tab, fg); + } else if (tabViewShowsFullSegments(tab, display_title)) { cacheBarSegmentArray(cache, renderer, label_x, label_space, tab.segments[0..tab.segments_len], ti); drawSegmentArray(renderer, label_x, text_y, label_space, tab.segments[0..tab.segments_len], fg); } else if (display_title.len > 0) { @@ -2376,7 +2467,7 @@ fn bottomBarHitTest(_: *App, mouse_x: f32, mouse_y: f32, window_width: f32) BarH return hitTestBar(&g_bottom_bar_cache, .bottom, mouse_x, mouse_y, window_width); } -fn updateBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32) BarHit { +fn updateBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32, modifiers: u32) BarHit { const bottom_hit = bottomBarHitTest(app, mouse_x, mouse_y, window_width); const top_hit = topBarHitTest(app, mouse_x, mouse_y, window_width); const hit = if (bottom_hit.surface != null) bottom_hit else top_hit; @@ -2386,7 +2477,8 @@ fn updateBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32) BarH } }); if (hit.surface == .top) { if (hit.node_id) |id| { - app.emitLuaBuiltInEvent("topbar:hover", .{ .topbar_node = .{ .id = id } }); + const mods = ghosttyMods(modifiers); + app.emitLuaBuiltInEvent("topbar:hover", .{ .topbar_node = .{ .id = id, .mods = mods, .shifted = (modifiers & c.SAPP_MODIFIER_SHIFT) != 0 } }); } else { app.emitLuaBuiltInEvent("topbar:leave", .none); } @@ -2396,21 +2488,23 @@ fn updateBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32) BarH app.emitLuaBuiltInEvent("topbar:leave", .none); if (hit.node_id) |id| { - app.emitLuaBuiltInEvent("bottombar:hover", .{ .bottombar_node = .{ .id = id } }); + const mods = ghosttyMods(modifiers); + app.emitLuaBuiltInEvent("bottombar:hover", .{ .bottombar_node = .{ .id = id, .mods = mods, .shifted = (modifiers & c.SAPP_MODIFIER_SHIFT) != 0 } }); } else { app.emitLuaBuiltInEvent("bottombar:leave", .none); } return hit; } -fn updateTopBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32) BarHit { +fn updateTopBarHover(app: *App, mouse_x: f32, mouse_y: f32, window_width: f32, modifiers: u32) BarHit { const hit = topBarHitTest(app, mouse_x, mouse_y, window_width); _ = app.enqueueMouse(.{ .hover = .{ .tab_index = hit.tab_index, .close_tab_index = hit.close_tab_index, } }); if (hit.node_id) |id| { - app.emitLuaBuiltInEvent("topbar:hover", .{ .topbar_node = .{ .id = id } }); + const mods = ghosttyMods(modifiers); + app.emitLuaBuiltInEvent("topbar:hover", .{ .topbar_node = .{ .id = id, .mods = mods, .shifted = (modifiers & c.SAPP_MODIFIER_SHIFT) != 0 } }); } else { app.emitLuaBuiltInEvent("topbar:leave", .none); } @@ -4913,22 +5007,24 @@ fn handleMouseButton(app: *App, event: c.sapp_event, action: ghostty.MouseAction } } - const bar_hit = updateBarHover(app, event.mouse_x, event.mouse_y, c.sapp_widthf()); + const bar_hit = updateBarHover(app, event.mouse_x, event.mouse_y, c.sapp_widthf(), event.modifiers); if (bar_hit.inBar()) { if (action == .press and event.mouse_button == c.SAPP_MOUSEBUTTON_LEFT) { + const click_mods = ghosttyMods(event.modifiers); + const click_shifted = (event.modifiers & c.SAPP_MODIFIER_SHIFT) != 0; if (bar_hit.node_id != null and bar_hit.tab_index == null) { if (bar_hit.surface == .top) { - app.emitLuaBuiltInEvent("topbar:click", .{ .topbar_node = .{ .id = bar_hit.node_id.? } }); + app.emitLuaBuiltInEvent("topbar:click", .{ .topbar_node = .{ .id = bar_hit.node_id.?, .mods = click_mods, .shifted = click_shifted } }); } else if (bar_hit.surface == .bottom) { - app.emitLuaBuiltInEvent("bottombar:click", .{ .bottombar_node = .{ .id = bar_hit.node_id.? } }); + app.emitLuaBuiltInEvent("bottombar:click", .{ .bottombar_node = .{ .id = bar_hit.node_id.?, .mods = click_mods, .shifted = click_shifted } }); } return; } if (bar_hit.tab_index) |ti| { if (bar_hit.surface == .top and bar_hit.node_id != null and bar_hit.close_tab_index == null) { - app.emitLuaBuiltInEvent("topbar:click", .{ .topbar_node = .{ .id = bar_hit.node_id.? } }); + app.emitLuaBuiltInEvent("topbar:click", .{ .topbar_node = .{ .id = bar_hit.node_id.?, .mods = click_mods, .shifted = click_shifted } }); } else if (bar_hit.surface == .bottom and bar_hit.node_id != null) { - app.emitLuaBuiltInEvent("bottombar:click", .{ .bottombar_node = .{ .id = bar_hit.node_id.? } }); + app.emitLuaBuiltInEvent("bottombar:click", .{ .bottombar_node = .{ .id = bar_hit.node_id.?, .mods = click_mods, .shifted = click_shifted } }); } else if (bar_hit.close_tab_index != null and bar_hit.close_tab_index.? == ti) { _ = app.enqueueMouse(.{ .close_tab_at = ti }); } else { @@ -5212,7 +5308,7 @@ fn handleMouseMove(app: *App, event: c.sapp_event) void { } } - const bar_hit = updateBarHover(app, event.mouse_x, event.mouse_y, c.sapp_widthf()); + const bar_hit = updateBarHover(app, event.mouse_x, event.mouse_y, c.sapp_widthf(), event.modifiers); if (bar_hit.inBar()) { g_scrollbar_hover_pane = null; g_hover_hyperlink = false; diff --git a/types/hollow.lua b/types/hollow.lua index 9b78c8d..7c6f540 100644 --- a/types/hollow.lua +++ b/types/hollow.lua @@ -208,12 +208,12 @@ ---@field ["quick_select:changed"] { active: boolean, action: "open"|"copy" } ---@field ["quick_select:no_matches"] {} ---@field ["quick_select:action_executed"] { text: string, kind: HollowQuickSelectKind, action: "open"|"copy"|"callback"|"command", pattern_index: integer|nil } ----@field ["topbar:hover"] { id: string } +---@field ["topbar:hover"] { id: string, mods: HollowKeyMods, shifted: boolean } ---@field ["topbar:leave"] {} ----@field ["topbar:click"] { id: string } ----@field ["bottombar:hover"] { id: string } +---@field ["topbar:click"] { id: string, mods: HollowKeyMods, shifted: boolean } +---@field ["bottombar:hover"] { id: string, mods: HollowKeyMods, shifted: boolean } ---@field ["bottombar:leave"] {} ----@field ["bottombar:click"] { id: string } +---@field ["bottombar:click"] { id: string, mods: HollowKeyMods, shifted: boolean } ---@field ["overlay:hover"] { id: string, index: integer|nil } ---@field ["overlay:leave"] {} ---@field ["overlay:click"] { id: string, index: integer|nil } @@ -237,9 +237,9 @@ ---@field radius? number Rounded corner radius for bg/border rect ---@field border? HollowColor Border color for bg rect outline ---@field border_size? number Border thickness in pixels ----@field on_click? fun(e: { id: string }) ----@field on_mouse_enter? fun(e: { id: string }) ----@field on_mouse_leave? fun(e: { id: string }) +---@field on_click? fun(e: { id: string, mods?: HollowKeyMods }) +---@field on_mouse_enter? fun(e: { id: string, mods?: HollowKeyMods }) +---@field on_mouse_leave? fun(e: { id: string, mods?: HollowKeyMods }) ---@alias HollowStyleValue HollowStyle|HollowColor ---@alias HollowUiNodeStyle HollowStyle @@ -480,6 +480,7 @@ ---@field title? string ---@field domain? string ---@field command? string +---@field insert_at_end? boolean ---@field on_complete? fun(result: { success: boolean, tab_id?: integer }) ---@class HollowSplitPaneOpts @@ -664,6 +665,8 @@ ---@class HollowUiBarNodePayload ---@field id string +---@field mods HollowKeyMods +---@field shifted boolean ---@class HollowUiBarTabsOptions: HollowUiBarNodeOptionsBase ---@field fit? "fill"|"content" @@ -697,12 +700,14 @@ ---@class HollowUiBarCustomNode ---@field _type "bar_custom" ---@field id? string +---@field style? HollowUiNodeStyle ---@field render fun(ctx: HollowWidgetCtx): HollowUiFormattedValue ----@field on_click? fun(e: { id: string }) ----@field on_mouse_enter? fun(e: { id: string }) ----@field on_mouse_leave? fun(e: { id: string }) +---@field on_click? fun(e: { id: string, mods: HollowKeyMods, shifted: boolean }) +---@field on_mouse_enter? fun(e: { id: string, mods: HollowKeyMods, shifted: boolean }) +---@field on_mouse_leave? fun(e: { id: string, mods?: HollowKeyMods }) ---@class HollowUiBarCustomOptions ---@field id? string +---@field style? HollowUiNodeStyle ---@field render fun(ctx:HollowWidgetCtx):HollowUiFormattedValue|HollowUiSegment|HollowUiNodeStyle|nil ---@field on_click? fun(payload:HollowUiNodeEventPayload) ---@field on_mouse_enter? fun(payload:HollowUiNodeEventPayload) @@ -823,6 +828,7 @@ ---@field workspace? false|HollowUiBarWorkspaceOptions ---@field tabs? false|HollowUiBarTabsOptions ---@field separator? false|string|HollowUiTopbarSeparatorOptions +---@field new_tab? false|{ id?: string, text?: string, style?: HollowUiNodeStyle } ---@field cwd? false|HollowUiTopbarCwdOptions ---@field key_legend? false|HollowUiBarKeyLegendOptions ---@field time? false|string|HollowUiTopbarTimeOptions @@ -1141,7 +1147,7 @@ ---@class HollowActionNamespace ---@field register fun(name: string, spec: HollowActionSpec) ---@field list fun(): HollowPaletteEntry[] ----@field [string] fun() +---@field [string] fun(opts?: table) ---@class HollowConfigNamespace local config = {} From 9c488ede70b2f19c227a44f04992fbf33f36f73c Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 9 Sep 2026 07:28:52 -0400 Subject: [PATCH 2/2] feat(topbar): track shift key state during tab hover for alternate actions Adds shift-aware hover tracking to topbar and bottombar widgets. When the shift key is held while hovering the new-tab button, it renders a down-arrow icon instead of "+" to indicate a shifted action. Physical shift key state is tracked separately from OS modifier reports to handle focus transitions correctly. Bar hover events are re-emitted on shift key changes to update the UI immediately. --- src/lua/hollow/state.lua | 2 + src/lua/hollow/ui/widgets/bars/bar_events.lua | 39 ++++++++++++++---- src/lua/hollow/ui/widgets/bars/topbar.lua | 22 +++++++--- src/lua/tests/test_ui_bars.lua | 39 ++++++++++++++++++ src/render/sokol_runtime.zig | 41 +++++++++++++++++++ types/hollow.lua | 4 +- 6 files changed, 132 insertions(+), 15 deletions(-) diff --git a/src/lua/hollow/state.lua b/src/lua/hollow/state.lua index 6acc23c..23fd9c3 100644 --- a/src/lua/hollow/state.lua +++ b/src/lua/hollow/state.lua @@ -104,6 +104,7 @@ function M.new(host_api) topbar_cache_state = nil, topbar_cache_layout = nil, topbar_hovered_id = nil, + topbar_hovered_shifted = false, topbar_handlers = {}, mounted_bottombar = nil, bottombar_cache_dirty = true, @@ -111,6 +112,7 @@ function M.new(host_api) bottombar_cache_state = nil, bottombar_cache_layout = nil, bottombar_hovered_id = nil, + bottombar_hovered_shifted = false, bottombar_handlers = {}, mounted_sidebar = nil, sidebar_visible = false, diff --git a/src/lua/hollow/ui/widgets/bars/bar_events.lua b/src/lua/hollow/ui/widgets/bars/bar_events.lua index cbad82e..0609bc4 100644 --- a/src/lua/hollow/ui/widgets/bars/bar_events.lua +++ b/src/lua/hollow/ui/widgets/bars/bar_events.lua @@ -52,6 +52,18 @@ function M.hovered_key(surface) return nil end +---@param surface string +---@return string|nil +function M.hovered_shifted_key(surface) + if surface == "topbar" then + return "topbar_hovered_shifted" + end + if surface == "bottombar" then + return "bottombar_hovered_shifted" + end + return nil +end + ---@param surface string|nil ---@param style any ---@return boolean @@ -165,11 +177,15 @@ function M.install(ui, active_widget, invalidate) return end local key = M.hovered_key(surface) + local shifted_key = M.hovered_shifted_key(surface) if kind == surface .. ":leave" then local id = state.ui[key] if id then call(surface, id, "on_mouse_leave", { id = id }) - state.ui[key] = nil + end + state.ui[key] = nil + state.ui[shifted_key] = false + if id then invalidate(surface) end return @@ -178,14 +194,21 @@ function M.install(ui, active_widget, invalidate) if not id then return end - if kind == surface .. ":hover" and state.ui[key] ~= id then - local old = state.ui[key] - if old then - call(surface, old, "on_mouse_leave", { id = old }) + if kind == surface .. ":hover" then + local shifted = payload.shifted == true + if state.ui[key] ~= id then + local old = state.ui[key] + if old then + call(surface, old, "on_mouse_leave", { id = old }) + end + state.ui[key] = id + state.ui[shifted_key] = shifted + call(surface, id, "on_mouse_enter", payload) + invalidate(surface) + elseif state.ui[shifted_key] ~= shifted then + state.ui[shifted_key] = shifted + invalidate(surface) end - state.ui[key] = id - call(surface, id, "on_mouse_enter", payload) - invalidate(surface) elseif kind == surface .. ":click" then call(surface, id, "on_click", payload) end diff --git a/src/lua/hollow/ui/widgets/bars/topbar.lua b/src/lua/hollow/ui/widgets/bars/topbar.lua index 6f4aa2c..bef4545 100644 --- a/src/lua/hollow/ui/widgets/bars/topbar.lua +++ b/src/lua/hollow/ui/widgets/bars/topbar.lua @@ -1,5 +1,4 @@ local attention = require("hollow.ui.widgets.attention") -local color = require("hollow.color") local shared = require("hollow.ui.shared") local hollow = _G.hollow local state = require("hollow.state").get() @@ -7,8 +6,8 @@ local ui = hollow.ui local tbl = hollow.tbl local util = hollow.util local M = {} -local BAR_CACHE_NO_EXPIRY = false -local DEFAULT_TOPBAR_HEIGHT = 22 +local DEFAULT_NEW_TAB_TEXT = "+" +local DEFAULT_SHIFTED_NEW_TAB_TEXT = "" local DEFAULT_TOPBAR_LAYOUT = { padding = { left = 1, right = 1, top = 1, bottom = 1 }, } @@ -116,10 +115,15 @@ local function configured_topbar_new_tab(value) local theme = shared.resolve_theme().ui local id = options.id or "new-tab-button" local style = M.merge_tables({ - fg = theme.widgets.all.title, + bg = theme.tab_bar.inactive_tab.bg, + fg = theme.tab_bar.inactive_tab.fg, + radius = 4, padding = { left = 5, right = 5, top = 1, bottom = 2 }, margin = { left = 1 }, - hover = { fg = theme.accent }, + hover = { + bg = theme.tab_bar.hover_tab.bg, + fg = theme.tab_bar.hover_tab.fg, + }, }, options.style) style.id = id @@ -127,7 +131,13 @@ local function configured_topbar_new_tab(value) id = id, style = style, render = function() - return options.text or "+" + local shifted = state.ui.topbar_hovered_id == id and state.ui.topbar_hovered_shifted + if shifted then + return ui.span(options.shifted_text or DEFAULT_SHIFTED_NEW_TAB_TEXT, { + padding = { left = 2, right = 7 }, + }) + end + return options.text or DEFAULT_NEW_TAB_TEXT end, on_click = function(event) if event and event.shifted then diff --git a/src/lua/tests/test_ui_bars.lua b/src/lua/tests/test_ui_bars.lua index f407172..c575f5d 100644 --- a/src/lua/tests/test_ui_bars.lua +++ b/src/lua/tests/test_ui_bars.lua @@ -79,6 +79,45 @@ describe("UI bars test suite", function() "new-tab-button", "configured topbar should include new-tab button" ) + harness.assert_equal( + topbar.items[1].text, + "+", + "new-tab button should show plus when not shifted" + ) + harness.assert_equal( + topbar.items[1].style.padding.left, + 5, + "plus should use symmetric left padding" + ) + harness.assert_equal( + topbar.items[1].style.padding.right, + 5, + "plus should use symmetric right padding" + ) + + hollow._emit_builtin_event("topbar:hover", { id = "new-tab-button", shifted = true }) + harness.assert_equal( + hollow.ui._topbar_state().items[1].text, + "", + "hovered new-tab button should show down arrow when shifted" + ) + harness.assert_equal( + hollow.ui._topbar_state().items[1].style.padding.left, + 2, + "shifted icon should use compensated left padding" + ) + harness.assert_equal( + hollow.ui._topbar_state().items[1].style.padding.right, + 7, + "shifted icon should use compensated right padding" + ) + + hollow._emit_builtin_event("topbar:hover", { id = "new-tab-button", shifted = false }) + harness.assert_equal( + hollow.ui._topbar_state().items[1].text, + "+", + "new-tab button should return to plus when Shift is released" + ) hollow._emit_builtin_event("topbar:click", { id = "new-tab-button", shifted = false }) harness.assert_equal(recorded.new_tab_calls, 1, "plain new-tab click should create a tab") diff --git a/src/render/sokol_runtime.zig b/src/render/sokol_runtime.zig index 8b4d350..35429fd 100644 --- a/src/render/sokol_runtime.zig +++ b/src/render/sokol_runtime.zig @@ -202,6 +202,8 @@ var g_drag_node: ?*SplitNode = null; var g_drag_direction: SplitDirection = .vertical; var g_drag_bounds: PaneBounds = .{ .x = 0, .y = 0, .width = 1, .height = 1 }; var g_mouse_button_down: ?ghostty.MouseButton = null; +var g_mouse_x: f32 = 0; +var g_mouse_y: f32 = 0; var g_top_bar_cache: BarCache = .{}; var g_bottom_bar_cache: BarCache = .{}; var g_overlay_hit_cache: struct { @@ -285,6 +287,8 @@ var g_swallow_char_until_frame: u64 = 0; /// held-bit model would wrongly strip them. Cleared on focus loss so the /// OS report is authoritative again on refocus. var g_released_mods: u32 = 0; +var g_shift_left_down = false; +var g_shift_right_down = false; var g_right_alt_down = false; var g_selection_pointer_active = false; var g_selection_pointer_pane: ?*Pane = null; @@ -4682,6 +4686,8 @@ fn eventCb(ev: [*c]const c.sapp_event, user_data: ?*anyopaque) callconv(.c) void // Focus is lost: trust the OS modifier report again on // restore (any pending key-ups went to another window). g_released_mods = 0; + g_shift_left_down = false; + g_shift_right_down = false; g_right_alt_down = false; cancelOverlayScrollbarDrag(); @atomicStore(bool, &g_window_iconified, true, .release); @@ -4702,6 +4708,8 @@ fn eventCb(ev: [*c]const c.sapp_event, user_data: ?*anyopaque) callconv(.c) void // Focus is lost: trust the OS modifier report again on // refocus (any pending key-ups went to another window). g_released_mods = 0; + g_shift_left_down = false; + g_shift_right_down = false; g_right_alt_down = false; cancelOverlayScrollbarDrag(); setMouseCursorHidden(false); @@ -4726,6 +4734,8 @@ fn eventCb(ev: [*c]const c.sapp_event, user_data: ?*anyopaque) callconv(.c) void // Focus is lost: trust the OS modifier report again on // restore (any pending key-ups went to another window). g_released_mods = 0; + g_shift_left_down = false; + g_shift_right_down = false; g_right_alt_down = false; cancelOverlayScrollbarDrag(); @atomicStore(bool, &g_window_iconified, true, .release); @@ -4744,6 +4754,8 @@ fn eventCb(ev: [*c]const c.sapp_event, user_data: ?*anyopaque) callconv(.c) void // Focus is lost: trust the OS modifier report again on // refocus (any pending key-ups went to another window). g_released_mods = 0; + g_shift_left_down = false; + g_shift_right_down = false; g_right_alt_down = false; cancelOverlayScrollbarDrag(); setMouseCursorHidden(false); @@ -4788,6 +4800,11 @@ fn handleKeyDown(app: *App, event: c.sapp_event) void { const mods = ghosttyMods(event.modifiers); const is_altgr = key != .alt_right and text_helpers.isAltGrMods(mods, g_right_alt_down); + setPhysicalShiftState(key, true); + if ((key == .shift_left or key == .shift_right) and g_mouse_over_window) { + _ = updateBarHover(app, g_mouse_x, g_mouse_y, c.sapp_widthf(), event.modifiers); + } + if (quick_select.inputActive(app)) { if (key == .escape) { enqueueQuickSelectInput(app, .cancel); @@ -4857,7 +4874,17 @@ fn handleKeyUp(app: *App, event: c.sapp_event) void { const mods = ghosttyMods(event.modifiers); const is_altgr = key != .alt_right and text_helpers.isAltGrMods(mods, g_right_alt_down); if (key == .alt_right) g_right_alt_down = false; + setPhysicalShiftState(key, false); g_released_mods |= modifierBitForKey(key); + if ((key == .shift_left or key == .shift_right) and g_mouse_over_window) { + var modifiers = event.modifiers; + if (physicalShiftHeld()) { + modifiers |= c.SAPP_MODIFIER_SHIFT; + } else { + modifiers &= ~@as(u32, c.SAPP_MODIFIER_SHIFT); + } + _ = updateBarHover(app, g_mouse_x, g_mouse_y, c.sapp_widthf(), modifiers); + } if (quick_select.inputActive(app)) { c.sapp_consume_event(); return; @@ -5209,6 +5236,8 @@ fn handleMouseButton(app: *App, event: c.sapp_event, action: ghostty.MouseAction fn handleMouseMove(app: *App, event: c.sapp_event) void { g_mouse_over_window = true; + g_mouse_x = event.mouse_x; + g_mouse_y = event.mouse_y; setMouseCursorHidden(false); if (g_linux_window_resize_active) { c.hollow_linux_update_window_resize(); @@ -5562,6 +5591,18 @@ fn mapKey(key_code: c.sapp_keycode) ghostty.Key { }; } +fn setPhysicalShiftState(key: ghostty.Key, down: bool) void { + switch (key) { + .shift_left => g_shift_left_down = down, + .shift_right => g_shift_right_down = down, + else => {}, + } +} + +fn physicalShiftHeld() bool { + return g_shift_left_down or g_shift_right_down; +} + fn modifierBitForKey(key: ghostty.Key) u32 { return switch (key) { .shift_left, .shift_right => ghostty.Mods.shift, diff --git a/types/hollow.lua b/types/hollow.lua index 7c6f540..b06ce02 100644 --- a/types/hollow.lua +++ b/types/hollow.lua @@ -828,7 +828,7 @@ ---@field workspace? false|HollowUiBarWorkspaceOptions ---@field tabs? false|HollowUiBarTabsOptions ---@field separator? false|string|HollowUiTopbarSeparatorOptions ----@field new_tab? false|{ id?: string, text?: string, style?: HollowUiNodeStyle } +---@field new_tab? false|{ id?: string, text?: string, shifted_text?: string, style?: HollowUiNodeStyle } ---@field cwd? false|HollowUiTopbarCwdOptions ---@field key_legend? false|HollowUiBarKeyLegendOptions ---@field time? false|string|HollowUiTopbarTimeOptions @@ -1083,8 +1083,10 @@ ---@field mounted_topbar HollowUiWidget|nil ---@field configured_topbar HollowUiTopbarConfigureOptions|nil ---@field topbar_hovered_id string|nil +---@field topbar_hovered_shifted boolean ---@field mounted_bottombar HollowUiWidget|nil ---@field bottombar_hovered_id string|nil +---@field bottombar_hovered_shifted boolean ---@field mounted_sidebar HollowUiWidget|nil ---@field sidebar_visible boolean ---@field overlay_stack HollowUiWidget[]