From 1fa9649bd602ecfc3bb35d9a3a17e6d5fd2ce8c1 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Wed, 24 Jun 2026 15:48:52 -0700 Subject: [PATCH] use single quote to toggle last symbol in tui --- TODO.md | 5 -- src/tui.zig | 167 +++++++++++++++++++++++++++++++++++++++++-- src/tui/keybinds.zig | 3 + 3 files changed, 163 insertions(+), 12 deletions(-) diff --git a/TODO.md b/TODO.md index a2f84d2..a1ae4b2 100644 --- a/TODO.md +++ b/TODO.md @@ -554,11 +554,6 @@ so they don't get lost; pick up opportunistically. the interaction model — e.g. allow specifying an expiration date, showing all monthlies expanded by default, or filtering by strategy (covered calls, spreads). -- **TUI: toggle to last symbol keybind.** A single-key toggle that - flips between the current symbol and the previously selected one - (like `cd -` in bash or `Ctrl+^` in vim). Store `last_symbol` on - `App`; on symbol change, stash the previous. Useful for - eyeball-comparing performance/risk data between two symbols. ### Options / valuation diff --git a/src/tui.zig b/src/tui.zig index 5a08893..8b73ed4 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -258,6 +258,57 @@ pub fn buildHelpLines( return lines.toOwnedSlice(arena); } +/// Decide whether the outgoing active symbol should be stashed as the +/// "last symbol" when switching to `incoming`. Stash only a non-empty +/// symbol that actually differs (case-insensitively) from the one +/// we're switching to, so `toggle_last_symbol` never records the +/// symbol you're already on as the thing to flip back to. +fn shouldStashSymbol(outgoing: []const u8, incoming: []const u8) bool { + return outgoing.len > 0 and !std.ascii.eqlIgnoreCase(outgoing, incoming); +} + +/// Post-swap state from `computeSymbolToggle`: the new active symbol +/// and the new last symbol, each slicing into the buffer passed for it. +const SymbolToggle = struct { + current: []const u8, + last: []const u8, +}; + +/// Swap the active symbol with the last-active symbol, writing the +/// results into the two backing buffers and returning slices into +/// them. Returns `null` when there is no previous symbol to toggle to +/// (`last` empty). +/// +/// Aliasing-safe: `current` and/or `last` may already point into +/// `current_buf` / `last_buf`. The outgoing current is copied to a +/// stack temp before either buffer is overwritten, so the swap holds +/// regardless of where the inputs live. Inputs longer than a buffer +/// are clamped to the buffer length (symbols are <= 16 bytes in +/// practice; the clamp just prevents an out-of-bounds copy). +fn computeSymbolToggle( + current: []const u8, + last: []const u8, + current_buf: *[16]u8, + last_buf: *[16]u8, +) ?SymbolToggle { + if (last.len == 0) return null; + + // SAFETY: only tmp[0..cur_len] is written (below) before being + // read back; the tail is never observed. + var tmp: [16]u8 = undefined; + const cur_len = @min(current.len, tmp.len); + @memcpy(tmp[0..cur_len], current[0..cur_len]); + + const new_cur_len = @min(last.len, current_buf.len); + @memcpy(current_buf[0..new_cur_len], last[0..new_cur_len]); + @memcpy(last_buf[0..cur_len], tmp[0..cur_len]); + + return .{ + .current = current_buf[0..new_cur_len], + .last = last_buf[0..cur_len], + }; +} + /// Per-tab state, owned by `App` and accessed as `app.states.`. /// /// Per-tab private state aggregator, derived at comptime from the @@ -454,6 +505,14 @@ pub const App = struct { // is unobservable. symbol_buf: [16]u8 = undefined, symbol_owned: bool = false, + /// The previously-active symbol, for the `toggle_last_symbol` + /// keybind (flip between current and prior, like `cd -`). Empty + /// until at least one symbol change has occurred. Bytes live in + /// `last_symbol_buf`. + last_symbol: []const u8 = "", + // SAFETY: paired with `last_symbol`; only the prefix + // last_symbol_buf[0..last_symbol.len] is ever read. + last_symbol_buf: [16]u8 = undefined, /// Symbol the overlay popup is currently showing details for. /// Empty when the overlay is closed. Bytes live in /// `overlay_symbol_buf`. Set/cleared via `toggleOverlay`. @@ -978,14 +1037,10 @@ pub const App = struct { .edited => return ctx.consumeAndRedraw(), .ignored => {}, .committed => { - // Commit: uppercase the input, set as active symbol, switch to quote tab + // Commit: set as active symbol (uppercased + stashed + // by setActiveSymbol), then switch to the quote tab. if (self.input_len > 0) { - for (self.input_buf[0..self.input_len]) |*ch| ch.* = std.ascii.toUpper(ch.*); - @memcpy(self.symbol_buf[0..self.input_len], self.input_buf[0..self.input_len]); - self.symbol = self.symbol_buf[0..self.input_len]; - self.symbol_owned = true; - self.has_explicit_symbol = true; - self.resetSymbolData(); + self.setActiveSymbol(self.input_buf[0..self.input_len]); self.active_tab = .quote; self.loadTabData(); ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); @@ -1022,6 +1077,13 @@ pub const App = struct { self.input_len = 0; return ctx.consumeAndRedraw(); }, + .toggle_last_symbol => { + if (self.toggleLastSymbol()) { + self.loadTabData(); + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); + } + return ctx.consumeAndRedraw(); + }, .refresh => { // Two-phase so the "Refreshing..." indicator paints // before the (blocking) refresh runs. Flag it and @@ -1201,6 +1263,13 @@ pub const App = struct { } pub fn setActiveSymbol(self: *App, sym: []const u8) void { + // Stash the outgoing symbol so `toggle_last_symbol` can flip + // back to it. Must run before symbol_buf is overwritten below. + if (shouldStashSymbol(self.symbol, sym)) { + const llen = @min(self.symbol.len, self.last_symbol_buf.len); + @memcpy(self.last_symbol_buf[0..llen], self.symbol[0..llen]); + self.last_symbol = self.last_symbol_buf[0..llen]; + } const len = @min(sym.len, self.symbol_buf.len); @memcpy(self.symbol_buf[0..len], sym[0..len]); for (self.symbol_buf[0..len]) |*c| c.* = std.ascii.toUpper(c.*); @@ -1210,6 +1279,28 @@ pub const App = struct { self.resetSymbolData(); } + /// Flip the active symbol to the previously-active one (and vice + /// versa). Backs the `toggle_last_symbol` keybind. Returns false + /// when there's no previous symbol yet (nothing to toggle to). + /// + /// Does NOT route through `setActiveSymbol`: the swap inherently + /// stashes the outgoing symbol as the new `last_symbol`, so an + /// extra stash would be redundant (and would alias the buffers). + fn toggleLastSymbol(self: *App) bool { + const swapped = computeSymbolToggle( + self.symbol, + self.last_symbol, + &self.symbol_buf, + &self.last_symbol_buf, + ) orelse return false; + self.symbol = swapped.current; + self.last_symbol = swapped.last; + self.symbol_owned = true; + self.has_explicit_symbol = true; + self.resetSymbolData(); + return true; + } + /// Open / close / re-target the symbol-info overlay. Behavior: /// - overlay closed → open with `sym`. /// - overlay open, same `sym` as cursor → close. @@ -2722,6 +2813,68 @@ test "formatStatusHint: single fragment has no separator" { try testing.expectEqualStrings("ctrl+s save", out); } +// ── symbol toggle helpers ───────────────────────────────────── + +test "shouldStashSymbol: stashes a differing non-empty symbol" { + try testing.expect(shouldStashSymbol("AAPL", "MSFT")); +} + +test "shouldStashSymbol: skips empty outgoing symbol" { + try testing.expect(!shouldStashSymbol("", "MSFT")); +} + +test "shouldStashSymbol: skips when unchanged (case-insensitive)" { + try testing.expect(!shouldStashSymbol("AAPL", "aapl")); +} + +test "computeSymbolToggle: null when there is no previous symbol" { + var cur = [_]u8{0} ** 16; + var last = [_]u8{0} ** 16; + try testing.expect(computeSymbolToggle("AAPL", "", &cur, &last) == null); +} + +test "computeSymbolToggle: swaps non-aliased inputs" { + var cur = [_]u8{0} ** 16; + var last = [_]u8{0} ** 16; + const r = computeSymbolToggle("AAPL", "MSFT", &cur, &last).?; + try testing.expectEqualStrings("MSFT", r.current); + try testing.expectEqualStrings("AAPL", r.last); +} + +test "computeSymbolToggle: aliasing-safe when slices point into the buffers" { + var cur = [_]u8{0} ** 16; + var last = [_]u8{0} ** 16; + @memcpy(cur[0..4], "AAPL"); + @memcpy(last[0..4], "MSFT"); + const r = computeSymbolToggle(cur[0..4], last[0..4], &cur, &last).?; + try testing.expectEqualStrings("MSFT", r.current); + try testing.expectEqualStrings("AAPL", r.last); +} + +test "computeSymbolToggle: double toggle returns to the original" { + var cur = [_]u8{0} ** 16; + var last = [_]u8{0} ** 16; + @memcpy(cur[0..4], "AAPL"); + @memcpy(last[0..4], "MSFT"); + + var r = computeSymbolToggle(cur[0..4], last[0..4], &cur, &last).?; + try testing.expectEqualStrings("MSFT", r.current); + try testing.expectEqualStrings("AAPL", r.last); + + r = computeSymbolToggle(r.current, r.last, &cur, &last).?; + try testing.expectEqualStrings("AAPL", r.current); + try testing.expectEqualStrings("MSFT", r.last); +} + +test "computeSymbolToggle: clamps inputs longer than the buffer" { + var cur = [_]u8{0} ** 16; + var last = [_]u8{0} ** 16; + const r = computeSymbolToggle("X", "ABCDEFGHIJKLMNOPQRSTUVWXYZ", &cur, &last).?; + try testing.expectEqual(@as(usize, 16), r.current.len); + try testing.expectEqualStrings("ABCDEFGHIJKLMNOP", r.current); + try testing.expectEqualStrings("X", r.last); +} + // ── interactive parseArgs ───────────────────────────────────── test "parseArgs: no args yields defaults" { diff --git a/src/tui/keybinds.zig b/src/tui/keybinds.zig index 02632fd..c98a691 100644 --- a/src/tui/keybinds.zig +++ b/src/tui/keybinds.zig @@ -25,6 +25,7 @@ pub const Action = enum { select_next, select_prev, symbol_input, + toggle_last_symbol, help, reload_portfolio, }; @@ -143,6 +144,7 @@ pub const global_default_bindings = [_]Binding{ .{ .action = .select_prev, .key = .{ .codepoint = 'k' } }, .{ .action = .select_prev, .key = .{ .codepoint = vaxis.Key.up } }, .{ .action = .symbol_input, .key = .{ .codepoint = '/' } }, + .{ .action = .toggle_last_symbol, .key = .{ .codepoint = '\'' } }, .{ .action = .help, .key = .{ .codepoint = '?' } }, .{ .action = .reload_portfolio, .key = .{ .codepoint = 'R' } }, }; @@ -178,6 +180,7 @@ pub const action_labels = std.enums.EnumArray(Action, []const u8).init(.{ .select_next = "Select next", .select_prev = "Select previous", .symbol_input = "Symbol input", + .toggle_last_symbol = "Toggle last symbol", .help = "Help", .reload_portfolio = "Reload portfolio", });