use single quote to toggle last symbol in tui
This commit is contained in:
parent
a9b5b8fe19
commit
1fa9649bd6
3 changed files with 163 additions and 12 deletions
5
TODO.md
5
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
|
||||
|
||||
|
|
|
|||
167
src/tui.zig
167
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.<tab>`.
|
||||
///
|
||||
/// 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" {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue