Compare commits
7 commits
378c0d0e84
...
020fb2db77
| Author | SHA1 | Date | |
|---|---|---|---|
| 020fb2db77 | |||
| 821c084f70 | |||
| 9f47d46d9a | |||
| 8b85dcb9ea | |||
| 8986537d93 | |||
| f1f665758c | |||
| 354a7e7799 |
11 changed files with 815 additions and 209 deletions
94
TODO.md
94
TODO.md
|
|
@ -39,9 +39,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
market return vs contributions" annotation would clarify how
|
||||
much of the trajectory was the model being right vs new money
|
||||
arriving on schedule.
|
||||
- Mosaic mode: overlay multiple as-of starting points on one chart
|
||||
("show me 1Y, 3Y, 5Y, 10Y projections all at once") so the user
|
||||
can see how the projection envelope tightened as data came in.
|
||||
- **Better composition basis for imported-only as-of.** Today
|
||||
the imported-only path uses today's allocations scaled by
|
||||
`imported_liquid / today_total_liquid`. That's the simplest
|
||||
|
|
@ -70,38 +67,6 @@ ranking; unlabeled items are "someday, if the mood strikes."
|
|||
faithfulness one notch. Pick whichever has the highest
|
||||
payoff vs. complexity when this gets revisited.
|
||||
|
||||
## `--export-chart` follow-ups - priority LOW
|
||||
|
||||
V1 of `--export-chart <PATH>` shipped for `quote`, `projections`
|
||||
(bands, `--convergence`, and `--return-backtest` modes), and
|
||||
`history`. Two adjacent surfaces still don't have PNG export:
|
||||
|
||||
- **`projections --vs <DATE>`.** No chart at all in this mode
|
||||
(text-only delta table); `--export-chart` rejected at parse
|
||||
time. Could grow a side-by-side bands comparison chart, but
|
||||
that's a feature of its own - not just an export plumbing job.
|
||||
- **Theme overrides at export time.** Today the export always
|
||||
uses `theme.default_theme`. A `--theme <PATH>` flag at export
|
||||
time would let users render with their configured theme or a
|
||||
presentation-friendly one. Out of scope for V1; gate when
|
||||
someone asks for it.
|
||||
|
||||
## Refactor: trim `src/format.zig` once Money / Date have absorbed their helpers - priority LOW
|
||||
|
||||
`src/format.zig` is still a ~1600-line grab-bag, but the money- and
|
||||
date-shaped helpers that used to live there have been moved out:
|
||||
money formatting now lives in `src/Money.zig` (with `{f}` /
|
||||
`whole()` / `trim()` / `signed()` / `padRight(N)` / `padLeft(N)`),
|
||||
date formatting lives in `src/Date.zig` (with `{f}` /
|
||||
`padRight(N)` / `padLeft(N)`), and the braille sparkline chart now
|
||||
lives in `src/charts/braille.zig`. What's left in `format.zig` is
|
||||
the genuinely-format-domain stuff: return formatters, allocation
|
||||
notes, signed-percent rendering.
|
||||
|
||||
If the file ever grows enough to be annoying again, consider
|
||||
renaming to `src/render.zig` to better describe what's left.
|
||||
Not blocking - file it as cleanup if and when it bites.
|
||||
|
||||
## Investigate: detailed 401(k) contributions data source
|
||||
|
||||
Found a more detailed contributions screen on at least one
|
||||
|
|
@ -130,46 +95,6 @@ opts ESPP/HSA accounts into cash-based attribution.
|
|||
Related: ESPP-style accrual blind spot in the "Audit: manual-check
|
||||
accounts mechanism" section above.
|
||||
|
||||
## Torn SRF files from server sync (root cause unknown)
|
||||
|
||||
**Status:** Root cause still unidentified. We have mitigations and
|
||||
diagnostics in place that keep torn responses from corrupting the
|
||||
cache, but we don't yet know *why* responses arrive torn. Until we
|
||||
have a root cause, this is not resolved - it's mitigated.
|
||||
|
||||
Mitigations landed so far:
|
||||
|
||||
- `syncFromServer` (`src/service.zig`) validates responses via
|
||||
`cache.Store.looksCompleteSrf` before `writeRaw`. Torn HTTP bodies
|
||||
(empty, missing `#!srfv1` header, or no trailing newline) are
|
||||
rejected with a warn-level log and NOT written to cache.
|
||||
- HTTP responses are checked for an `ETag` sha256 header; on mismatch
|
||||
we retry the request once before giving up and falling back to the
|
||||
provider.
|
||||
- Read-path self-heal: on SRF parse failure during read, the cache
|
||||
entry is invalidated so a subsequent refresh can repair without
|
||||
user intervention.
|
||||
- Diagnostics: richer error capture around the sync path. So far,
|
||||
HTTP transit is the dominant source of torn responses - but that's
|
||||
an observation, not a root cause.
|
||||
|
||||
**Remaining work:**
|
||||
|
||||
- Identify root cause. Candidates to investigate: proxy/load-balancer
|
||||
behavior, HTTP keepalive reuse, partial reads on the server side,
|
||||
client-side buffer handling. The etag retry tells us whether the
|
||||
problem is per-request or persistent; dig into the diagnostics
|
||||
output when the next occurrence is captured.
|
||||
- Once root cause is known, decide whether the current mitigations
|
||||
are sufficient or whether a targeted fix is needed. The
|
||||
mitigations may end up being the whole answer, but we can't
|
||||
conclude that without understanding the underlying cause.
|
||||
|
||||
(Content-Length validation was considered and rejected: once the
|
||||
server starts compressing response bodies, Content-Length reflects
|
||||
the compressed byte count, not the decoded payload, so it's not a
|
||||
reliable integrity check.)
|
||||
|
||||
## On-demand server-side fetch for new symbols
|
||||
|
||||
Currently the server's SRF endpoints (`/candles`, `/dividends`, etc.) are pure
|
||||
|
|
@ -330,25 +255,6 @@ taxonomy.
|
|||
The following items are acknowledged but not prioritized. Listed here
|
||||
so they don't get lost; pick up opportunistically.
|
||||
|
||||
### UX
|
||||
|
||||
- **CLI options command UX.** The `options` command auto-expands only
|
||||
the nearest monthly expiration and lists others collapsed. Reconsider
|
||||
the interaction model - e.g. allow specifying an expiration date,
|
||||
showing all monthlies expanded by default, or filtering by strategy
|
||||
(covered calls, spreads).
|
||||
|
||||
### Audit
|
||||
|
||||
- **Audit large-lot threshold tuning.** `src/commands/audit.zig` uses
|
||||
`audit_large_lot_threshold: f64 = 10_000.0` as the cutoff for
|
||||
"surface this new lot for confirmation." Revisit if $10k proves too
|
||||
aggressive (ESPP accruals spam the report) or too permissive (large
|
||||
DRIP confirmations slip past). If runtime tuning becomes necessary,
|
||||
a `--large-lot <amount>` flag or a global
|
||||
`audit_large_lot_threshold` field on `accounts.srf` would be
|
||||
reasonable extensions.
|
||||
|
||||
### Infra / performance
|
||||
|
||||
- **HTTP connection pooling.** Parallel server sync in `loadAllPrices`
|
||||
|
|
|
|||
|
|
@ -67,8 +67,8 @@ account::Old Rollover,tax_type::traditional,update_cadence::none
|
|||
|
||||
## 4. Advanced flags
|
||||
|
||||
Two flags change how analysis treats an account. Both are optional --
|
||||
see the reference for details:
|
||||
Three optional fields change how analysis and the audit treat an
|
||||
account -- see the reference for details:
|
||||
|
||||
- **`shielded:bool:false`** -- mark a pre-tax account that is *not*
|
||||
judgment-protected (deferred comp, a weak-state IRA) so it counts
|
||||
|
|
@ -79,6 +79,15 @@ see the reference for details:
|
|||
account as real external contributions in
|
||||
[`zfin contributions`](track-contributions.md), instead of internal
|
||||
noise.
|
||||
- **`audit_large_lot_threshold:num:50000`** -- raise (or lower) the
|
||||
dollar cutoff at which a flagless [`zfin audit`](../reference/cli/audit.md)
|
||||
nudges you to confirm a **new lot**'s source. The default is $10,000;
|
||||
bump it on a noisy ESPP/payroll account so routine accruals stop
|
||||
spamming the report, while leaving quieter accounts at the default:
|
||||
|
||||
```srf
|
||||
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||||
```
|
||||
|
||||
## Example (from `examples/pre-retirement-both`)
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@ Reconciliation matches export accounts to yours via `institution::` and
|
|||
`account_number::` in [`accounts.srf`](../config/accounts-srf.md); an
|
||||
unmatched account is reported as "unmapped."
|
||||
|
||||
The hygiene check also flags newly-appeared lots worth at least
|
||||
$10,000 in a **Large new lots - confirm source** section, so you can
|
||||
confirm whether each is a real contribution or an unrecorded transfer.
|
||||
The cutoff is per account -- raise or lower it on an account's record
|
||||
via [`audit_large_lot_threshold`](../config/accounts-srf.md#audit_large_lot_threshold)
|
||||
in `accounts.srf` (e.g. to silence a noisy ESPP account).
|
||||
|
||||
## Example (hygiene check)
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -24,16 +24,17 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
|
|||
|
||||
## Fields
|
||||
|
||||
| Field | Type | Required | Default | Description |
|
||||
|------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. |
|
||||
| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. |
|
||||
| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. |
|
||||
| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. |
|
||||
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
|
||||
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
|
||||
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
|
||||
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
|
||||
| Field | Type | Required | Default | Description |
|
||||
|-----------------------------|--------|----------|-----------|----------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `account` | string | Yes | -- | Account name; must match `account::` on lots exactly. |
|
||||
| `tax_type` | string | Yes | -- | `taxable`, `roth`, `traditional`, or `hsa`. |
|
||||
| `institution` | string | No | -- | Broker key, e.g. `fidelity`, `schwab`, `vanguard`, `wells_fargo`. Used by [`zfin audit`](../cli/audit.md) to match export files. |
|
||||
| `account_number` | string | No | -- | Account identifier used with `institution` for audit matching. Use a placeholder, not a full real number. |
|
||||
| `update_cadence` | string | No | `weekly` | How often you refresh this account's manual data: `weekly`, `monthly`, `quarterly`, or `none`. Drives the audit staleness nag. |
|
||||
| `cash_is_contribution` | bool | No | `false` | When `true`, raw cash-balance increases on this account count as real external contributions (see below). |
|
||||
| `direct_indexing` | bool | No | `false` | Marks an account whose lots track a benchmark with tracking-error drift (loosens contribution/audit tolerances). |
|
||||
| `shielded` | bool | No | (derived) | Umbrella-exposure override (see below). |
|
||||
| `audit_large_lot_threshold` | num | No | `10000` | Per-account dollar cutoff for the audit "Large new lots" nudge (see below). Must be positive. |
|
||||
|
||||
## Tax types
|
||||
|
||||
|
|
@ -47,6 +48,33 @@ account::Joint taxable,tax_type::taxable,institution::schwab,account_number::JT0
|
|||
Any other value is shown as-is. Accounts missing from `accounts.srf`
|
||||
appear as "Unknown".
|
||||
|
||||
## `audit_large_lot_threshold`
|
||||
|
||||
When [`zfin audit`](../cli/audit.md) runs flagless, its **Large new
|
||||
lots - confirm source** section flags any newly-appeared lot worth at
|
||||
least this many dollars, nudging you to confirm whether it's a real
|
||||
external contribution or an unrecorded internal transfer. Smaller new
|
||||
lots pass silently so routine payroll/ESPP accruals and weekly deposits
|
||||
don't spam the report.
|
||||
|
||||
The threshold is **per account**, because the noise it fights is
|
||||
account-specific: an ESPP or payroll account that accrues routine large
|
||||
lots wants a high bar, while a taxable brokerage where any sizeable new
|
||||
lot deserves a look wants the default (or lower). Set it on the
|
||||
account's own record:
|
||||
|
||||
```srf
|
||||
#!srfv1
|
||||
account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||||
account::Sample Brokerage,tax_type::taxable
|
||||
```
|
||||
|
||||
Here the ESPP account stays quiet until a new lot tops $50k, while
|
||||
`Sample Brokerage` (no override) uses the built-in `$10,000` default.
|
||||
Accounts you don't list, or list without the field, use that default.
|
||||
The value must be **positive** -- zero or a negative number is rejected
|
||||
at load time and the account falls back to the default.
|
||||
|
||||
## `update_cadence` and the audit nag
|
||||
|
||||
[`zfin audit`](../cli/audit.md) (run flagless) flags accounts you
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
/// Takes portfolio allocations (with market values) and classification metadata,
|
||||
/// produces breakdowns by asset class, sector, geographic region, account, and tax type.
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const srf = @import("srf");
|
||||
const Allocation = @import("valuation.zig").Allocation;
|
||||
const ClassificationMap = @import("../models/classification.zig").ClassificationMap;
|
||||
|
|
@ -10,6 +11,8 @@ const ClassificationEntry = @import("../models/classification.zig").Classificati
|
|||
const Portfolio = @import("../models/portfolio.zig").Portfolio;
|
||||
const Date = @import("../Date.zig");
|
||||
|
||||
const log = std.log.scoped(.accounts);
|
||||
|
||||
/// A single slice of a breakdown (e.g., "Technology" -> 25.3%)
|
||||
pub const BreakdownItem = struct {
|
||||
label: []const u8,
|
||||
|
|
@ -94,6 +97,22 @@ pub const AccountTaxEntry = struct {
|
|||
/// `shielded:bool:false` on their IRA accounts to get a
|
||||
/// correct umbrella-exposure number.
|
||||
shielded: ?bool = null,
|
||||
/// Optional per-account override for the dollar threshold above
|
||||
/// which `zfin audit` flags a new lot in its "Large new lots -
|
||||
/// confirm source" section. Null means "use the audit's built-in
|
||||
/// default" (`contributions.default_audit_large_lot_threshold`, $10k).
|
||||
///
|
||||
/// The right knob is per-account because the noise this nudge
|
||||
/// fights is account-specific: an ESPP/payroll account that
|
||||
/// accrues routine large lots wants a HIGH threshold to stay
|
||||
/// quiet, while a taxable brokerage where any sizeable new lot is
|
||||
/// worth a look wants the default (or lower). Set it higher to cut
|
||||
/// ESPP spam, lower to catch smaller movements.
|
||||
///
|
||||
/// Must be positive; a zero or negative value is rejected at parse
|
||||
/// time (warned + treated as unset) since zero would flag every
|
||||
/// new lot and negative is meaningless.
|
||||
audit_large_lot_threshold: ?f64 = null,
|
||||
};
|
||||
|
||||
/// Update cadence for manual account maintenance. Parsed from accounts.srf.
|
||||
|
|
@ -196,10 +215,26 @@ pub const AccountMap = struct {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Per-account override for the audit "Large new lots" dollar
|
||||
/// threshold. Returns the account's configured value, or null to
|
||||
/// fall back to the audit's built-in default
|
||||
/// (`contributions.default_audit_large_lot_threshold`). Null both when
|
||||
/// the account isn't in the map and when its entry omits the
|
||||
/// field. Parse guarantees any non-null result is positive.
|
||||
pub fn largeLotThresholdFor(self: AccountMap, account: []const u8) ?f64 {
|
||||
for (self.entries) |e| {
|
||||
if (std.mem.eql(u8, e.account, account)) {
|
||||
return e.audit_large_lot_threshold;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Parse an accounts.srf file into an AccountMap.
|
||||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>]
|
||||
/// Each record has: account::<NAME>,tax_type::<TYPE>[,institution::<INST>][,account_number::<NUM>][,<flags>]
|
||||
/// where the optional flags include `audit_large_lot_threshold:num:<DOLLARS>`.
|
||||
pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !AccountMap {
|
||||
var entries = std.ArrayList(AccountTaxEntry).empty;
|
||||
errdefer {
|
||||
|
|
@ -217,6 +252,20 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
|
||||
while (try it.next()) |fields| {
|
||||
const entry = fields.to(AccountTaxEntry, .{}) catch continue;
|
||||
|
||||
// A zero/negative large-lot threshold is nonsensical (zero
|
||||
// flags every new lot; negative is meaningless). Reject it and
|
||||
// treat the account as unset so the audit uses its default.
|
||||
const lot_threshold: ?f64 = if (entry.audit_large_lot_threshold) |t| blk: {
|
||||
if (t > 0) break :blk t;
|
||||
// No-op under `zig build test`: the parser's own tests feed
|
||||
// invalid thresholds (0, negative) on purpose to verify they
|
||||
// are rejected, and the warn spam pollutes test output.
|
||||
if (!builtin.is_test)
|
||||
log.warn("accounts.srf: account '{s}': audit_large_lot_threshold must be > 0 (got {d}); ignoring", .{ entry.account, t });
|
||||
break :blk null;
|
||||
} else null;
|
||||
|
||||
try entries.append(allocator, .{
|
||||
.account = try allocator.dupe(u8, entry.account),
|
||||
.tax_type = entry.tax_type,
|
||||
|
|
@ -226,6 +275,7 @@ pub fn parseAccountsFile(allocator: std.mem.Allocator, data: []const u8) !Accoun
|
|||
.cash_is_contribution = entry.cash_is_contribution,
|
||||
.direct_indexing = entry.direct_indexing,
|
||||
.shielded = entry.shielded,
|
||||
.audit_large_lot_threshold = lot_threshold,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -945,6 +995,71 @@ test "parseAccountsFile: shielded:bool:true override (rare, e.g. asset-protectio
|
|||
try std.testing.expect(am.entries[0].shielded.?);
|
||||
}
|
||||
|
||||
test "parseAccountsFile: audit_large_lot_threshold omitted -> null (use audit default)" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\account::Sample Roth,tax_type::roth
|
||||
\\account::Sample Brokerage,tax_type::taxable
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
var am = try parseAccountsFile(allocator, data);
|
||||
defer am.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||||
// No override on either account -> lookup returns null so the
|
||||
// audit falls back to its built-in default.
|
||||
try std.testing.expect(am.entries[0].audit_large_lot_threshold == null);
|
||||
try std.testing.expect(am.largeLotThresholdFor("Sample Roth") == null);
|
||||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||||
}
|
||||
|
||||
test "parseAccountsFile: per-account audit_large_lot_threshold parses and is looked up by account" {
|
||||
// Mixed: one account raises its threshold (e.g. a noisy ESPP
|
||||
// account), a sibling leaves it default.
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||||
\\account::Sample Brokerage,tax_type::taxable
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
var am = try parseAccountsFile(allocator, data);
|
||||
defer am.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 2), am.entries.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 50000.0), am.largeLotThresholdFor("Sample ESPP").?, 0.01);
|
||||
// Sibling account has no override.
|
||||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||||
// Unknown account -> null (falls back to default downstream).
|
||||
try std.testing.expect(am.largeLotThresholdFor("Nonexistent") == null);
|
||||
}
|
||||
|
||||
test "parseAccountsFile: audit_large_lot_threshold accepts a fractional value" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
\\account::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:7500.5
|
||||
;
|
||||
const allocator = std.testing.allocator;
|
||||
var am = try parseAccountsFile(allocator, data);
|
||||
defer am.deinit();
|
||||
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 7500.5), am.largeLotThresholdFor("Sample Brokerage").?, 0.001);
|
||||
}
|
||||
|
||||
test "parseAccountsFile: non-positive audit_large_lot_threshold is rejected -> null" {
|
||||
// Zero and negative thresholds are nonsensical (zero flags every
|
||||
// new lot; negative is meaningless). They're dropped at parse
|
||||
// time so the audit falls back to its built-in default. The
|
||||
// surrounding account still parses.
|
||||
inline for (.{ "0", "-5000" }) |bad| {
|
||||
const data = "#!srfv1\naccount::Sample Brokerage,tax_type::taxable,audit_large_lot_threshold:num:" ++ bad ++ "\n";
|
||||
const allocator = std.testing.allocator;
|
||||
var am = try parseAccountsFile(allocator, data);
|
||||
defer am.deinit();
|
||||
try std.testing.expectEqual(@as(usize, 1), am.entries.len);
|
||||
try std.testing.expect(am.largeLotThresholdFor("Sample Brokerage") == null);
|
||||
}
|
||||
}
|
||||
|
||||
// ── umbrellaExposure ─────────────────────────────────────────
|
||||
|
||||
/// Helper: build an in-memory AccountMap from a literal SRF
|
||||
|
|
|
|||
|
|
@ -37,20 +37,6 @@ const audit_file_max_size_non_csv = 512 * 1024; // 512KB, for non-CSV files only
|
|||
pub const default_stale_days: u32 = 3;
|
||||
const stale_warning_multiplier: u32 = 2; // yellow -> red at 2× threshold
|
||||
|
||||
/// Dollar threshold above which a new lot (new_stock / new_drip_lot /
|
||||
/// new_cash / new_cd / cash_contribution) gets flagged in the
|
||||
/// "Large new lots - confirm source" hygiene section. Below this
|
||||
/// threshold new lots pass silently - the audit's goal is to catch
|
||||
/// unconfirmed six-figure movements, not flag every payroll
|
||||
/// contribution.
|
||||
///
|
||||
/// $10k is a judgment call: high enough to ignore routine payroll
|
||||
/// ESPP accruals and $1-$2k weekly deposits, low enough to surface
|
||||
/// a typical IRA contribution or a genuine transfer. Tunable here,
|
||||
/// per the plan's "revisit if the threshold proves wrong" note in
|
||||
/// TODO.md.
|
||||
const audit_large_lot_threshold: f64 = 10_000.0;
|
||||
|
||||
/// Type of a discovered brokerage file.
|
||||
const BrokerFileKind = enum {
|
||||
fidelity_csv,
|
||||
|
|
@ -992,9 +978,10 @@ pub fn runHygieneCheck(
|
|||
//
|
||||
// Silent when every large lot matched a transfer record, when
|
||||
// there are no new lots at all, or when the pipeline can't run
|
||||
// (not in a git repo). Threshold is a judgment call; see
|
||||
// `audit_large_lot_threshold`.
|
||||
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, audit_large_lot_threshold, as_of, color, refresh)) |found| {
|
||||
// (not in a git repo). Threshold is per-account: an account's
|
||||
// `audit_large_lot_threshold` in accounts.srf wins, otherwise the
|
||||
// filter's built-in default applies.
|
||||
if (contributions.findUnmatchedLargeLots(io, allocator, env, svc, portfolio_path, &account_map, as_of, color, refresh)) |found| {
|
||||
var found_mut = found;
|
||||
defer found_mut.deinit();
|
||||
|
||||
|
|
|
|||
|
|
@ -933,9 +933,25 @@ pub fn computeAttributionSpec(
|
|||
|
||||
// ── Public audit hook ────────────────────────────────────────
|
||||
|
||||
/// Default dollar threshold above which a new lot (new_stock /
|
||||
/// new_drip_lot / new_cash / new_cd / cash_contribution) gets flagged
|
||||
/// in the audit "Large new lots - confirm source" section. Below this
|
||||
/// threshold new lots pass silently - the goal is to catch unconfirmed
|
||||
/// six-figure movements, not flag every payroll contribution.
|
||||
///
|
||||
/// $10k is a judgment call: high enough to ignore routine payroll ESPP
|
||||
/// accruals and $1-$2k weekly deposits, low enough to surface a typical
|
||||
/// IRA contribution or a genuine transfer. This is only the fallback -
|
||||
/// an account can override it per-account with an
|
||||
/// `audit_large_lot_threshold:num:<DOLLARS>` field on its accounts.srf
|
||||
/// record (see `AccountTaxEntry.audit_large_lot_threshold` /
|
||||
/// `AccountMap.largeLotThresholdFor`), so a noisy ESPP account can
|
||||
/// raise its bar without going blind on a quiet brokerage account.
|
||||
const default_audit_large_lot_threshold: f64 = 10_000.0;
|
||||
|
||||
/// Descriptor of a "large new lot" the audit command may want to
|
||||
/// surface. Emitted by `findUnmatchedLargeLots` for any new-side
|
||||
/// Change whose `value()` meets the caller's threshold and which
|
||||
/// Change whose `value()` meets the resolved threshold and which
|
||||
/// was NOT reclassified by the transfer-log matcher. All string
|
||||
/// fields are caller-arena-owned through the `UnmatchedLargeLotSet`
|
||||
/// wrapper; the caller frees everything at once via `deinit`.
|
||||
|
|
@ -966,12 +982,18 @@ pub const UnmatchedLargeLotSet = struct {
|
|||
};
|
||||
|
||||
/// Find new-side lots (new_stock / new_drip_lot / new_cash / new_cd
|
||||
/// / cash_contribution) with `value() >= threshold` that weren't
|
||||
/// matched to a record in `transaction_log.srf` over the HEAD ->
|
||||
/// working-copy window. Mirrors the `zfin contributions` zero-flag
|
||||
/// path - uses `prepareReport`'s shared git + portfolio + transfer
|
||||
/// plumbing so the classification is identical. Returns null if the
|
||||
/// pipeline can't resolve a window (not in a git repo, etc.).
|
||||
/// / cash_contribution) whose unattributed value meets the audit
|
||||
/// large-lot threshold but weren't matched to a record in
|
||||
/// `transaction_log.srf` over the HEAD -> working-copy window.
|
||||
/// Mirrors the `zfin contributions` zero-flag path - uses
|
||||
/// `prepareReport`'s shared git + portfolio + transfer plumbing so
|
||||
/// the classification is identical. Returns null if the pipeline
|
||||
/// can't resolve a window (not in a git repo, etc.).
|
||||
///
|
||||
/// The threshold is resolved per lot: an account's
|
||||
/// `audit_large_lot_threshold` (from `account_map`) wins, otherwise
|
||||
/// `default_audit_large_lot_threshold` applies. Pass `account_map =
|
||||
/// null` to use the default for everything.
|
||||
///
|
||||
/// Consumed by `zfin audit` to prompt the user to either confirm
|
||||
/// the lot as an external contribution or add a transfer record
|
||||
|
|
@ -984,7 +1006,7 @@ pub fn findUnmatchedLargeLots(
|
|||
env: *const std.process.Environ.Map,
|
||||
svc: *zfin.DataService,
|
||||
portfolio_path: []const u8,
|
||||
threshold: f64,
|
||||
account_map: ?*const analysis.AccountMap,
|
||||
as_of: Date,
|
||||
color: bool,
|
||||
refresh: framework.RefreshPolicy,
|
||||
|
|
@ -1007,7 +1029,7 @@ pub fn findUnmatchedLargeLots(
|
|||
};
|
||||
defer ctx.deinit();
|
||||
|
||||
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, threshold) catch {
|
||||
const lots = collectUnmatchedLargeLots(arena, ctx.report.changes, account_map) catch {
|
||||
arena_state.deinit();
|
||||
return null;
|
||||
};
|
||||
|
|
@ -1061,7 +1083,7 @@ pub fn findUnmatchedLargeLots(
|
|||
fn collectUnmatchedLargeLots(
|
||||
arena: std.mem.Allocator,
|
||||
changes: []const Change,
|
||||
threshold: f64,
|
||||
account_map: ?*const analysis.AccountMap,
|
||||
) ![]UnmatchedLargeLot {
|
||||
var out: std.ArrayList(UnmatchedLargeLot) = .empty;
|
||||
for (changes) |c| {
|
||||
|
|
@ -1071,6 +1093,14 @@ fn collectUnmatchedLargeLots(
|
|||
};
|
||||
if (!is_new_side) continue;
|
||||
|
||||
// Per-account threshold: the lot's account can raise/lower its
|
||||
// own cutoff (e.g. a noisy ESPP account); otherwise the
|
||||
// built-in default applies.
|
||||
const threshold = if (account_map) |am|
|
||||
(am.largeLotThresholdFor(c.account) orelse default_audit_large_lot_threshold)
|
||||
else
|
||||
default_audit_large_lot_threshold;
|
||||
|
||||
// Use attributedValue() so a fully-attributed cash lot
|
||||
// (whose `transfer_attributed` covers the whole `value()`)
|
||||
// drops out, and a partially-attributed cash lot surfaces
|
||||
|
|
@ -5684,7 +5714,7 @@ test "collectUnmatchedLargeLots: below threshold is silent" {
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
|
@ -5704,7 +5734,7 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try std.testing.expectEqualStrings("Acct A", lots[0].account);
|
||||
|
|
@ -5714,6 +5744,46 @@ test "collectUnmatchedLargeLots: unmatched large stock lot surfaces" {
|
|||
try std.testing.expectEqual(Date.fromYmd(2026, 5, 3).days, lots[0].open_date.days);
|
||||
}
|
||||
|
||||
test "collectUnmatchedLargeLots: per-account threshold suppresses one account, default flags the other" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
const allocator = arena_state.allocator();
|
||||
var prices = std.StringHashMap(f64).init(allocator);
|
||||
defer prices.deinit();
|
||||
try prices.put("ESPPSYM", 300.0);
|
||||
try prices.put("BRKSYM", 300.0);
|
||||
|
||||
const before = [_]Lot{};
|
||||
// Two new $30k lots in different accounts.
|
||||
const after = [_]Lot{
|
||||
.{ .symbol = "ESPPSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample ESPP" },
|
||||
.{ .symbol = "BRKSYM", .shares = 100, .open_date = Date.fromYmd(2026, 5, 3), .open_price = 300, .account = "Sample Brokerage" },
|
||||
};
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
|
||||
// Sanity: with no account_map, both $30k lots clear the $10k
|
||||
// default and surface. This isolates the override as the cause of
|
||||
// the difference below.
|
||||
const both = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 2), both.len);
|
||||
|
||||
// ESPP raises its own threshold to $50k (routine large accruals);
|
||||
// Sample Brokerage leaves it at the default. Now only the
|
||||
// brokerage lot surfaces - the $30k ESPP lot is below its $50k bar.
|
||||
var am = try analysis.parseAccountsFile(allocator,
|
||||
\\#!srfv1
|
||||
\\account::Sample ESPP,tax_type::taxable,audit_large_lot_threshold:num:50000
|
||||
\\account::Sample Brokerage,tax_type::taxable
|
||||
);
|
||||
defer am.deinit();
|
||||
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, &am);
|
||||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try std.testing.expectEqualStrings("Sample Brokerage", lots[0].account);
|
||||
try std.testing.expectEqualStrings("BRKSYM", lots[0].symbol);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 30_000.0), lots[0].value, 0.01);
|
||||
}
|
||||
|
||||
test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
|
||||
var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena_state.deinit();
|
||||
|
|
@ -5727,7 +5797,7 @@ test "collectUnmatchedLargeLots: unmatched large cash lot surfaces" {
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 11), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try std.testing.expectEqual(LotType.cash, lots[0].security_type);
|
||||
|
|
@ -5761,7 +5831,7 @@ test "collectUnmatchedLargeLots: matched via transfer log is silent" {
|
|||
// Sanity: the lot should have been reclassified.
|
||||
try std.testing.expectEqual(ChangeKind.transfer_in, report.changes[0].kind);
|
||||
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5811,7 +5881,7 @@ test "collectUnmatchedLargeLots: cash-destination matched is silent" {
|
|||
try std.testing.expectEqual(@as(f64, 73158.33), attributed);
|
||||
|
||||
// The audit filter must subtract the attribution and stay quiet.
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5841,7 +5911,7 @@ test "collectUnmatchedLargeLots: cash-destination partial match surfaces residua
|
|||
.transfer_log = tlog.transfers,
|
||||
});
|
||||
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try std.testing.expectEqual(@as(f64, 20000.0), lots[0].value);
|
||||
}
|
||||
|
|
@ -5870,7 +5940,7 @@ test "collectUnmatchedLargeLots: cash-destination partial below threshold is sil
|
|||
.transfer_log = tlog.transfers,
|
||||
});
|
||||
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5885,7 +5955,7 @@ test "collectUnmatchedLargeLots: no new lots -> empty result" {
|
|||
const after = [_]Lot{};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5909,7 +5979,7 @@ test "collectUnmatchedLargeLots: buy funded by same-account cash is silent" {
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5930,7 +6000,7 @@ test "collectUnmatchedLargeLots: partly cash-funded buy surfaces residual only"
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 1), lots.len);
|
||||
try std.testing.expectApproxEqAbs(@as(f64, 20_000.0), lots[0].value, 0.01);
|
||||
}
|
||||
|
|
@ -5952,7 +6022,7 @@ test "collectUnmatchedLargeLots: partial cash-funded residual below threshold is
|
|||
};
|
||||
|
||||
const report = try computeReport(allocator, &before, &after, &prices, Date.fromYmd(2026, 5, 4), .{});
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
@ -5991,7 +6061,7 @@ test "collectUnmatchedLargeLots: partial transfer still flags residual? No - ful
|
|||
|
||||
try std.testing.expectEqual(ChangeKind.partial_transfer_in, report.changes[0].kind);
|
||||
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, 10_000.0);
|
||||
const lots = try collectUnmatchedLargeLots(allocator, report.changes, null);
|
||||
try std.testing.expectEqual(@as(usize, 0), lots.len);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -430,6 +430,32 @@ test "parse: single ack with two notes round-trips" {
|
|||
try testing.expectEqualStrings("Will trim by Q3 2026.", entry.notes[1]);
|
||||
}
|
||||
|
||||
test "round-trip: a note with emoji and accented text survives format + parse" {
|
||||
// UTF-8 note content (emoji, accented letters) must survive the
|
||||
// SRF format -> parse cycle byte-for-byte. Multi-byte sequences
|
||||
// can't collide with SRF's ASCII delimiters (',' '::' newline), so
|
||||
// this is safe; the test pins that guarantee for the ack-note flow.
|
||||
const a = testing.allocator;
|
||||
const records = [_]JournalRecord{
|
||||
.{ .acknowledgment = .{
|
||||
.observation = "position_concentration",
|
||||
.target = "NVDA",
|
||||
.acknowledged_at = Date.fromYmd(2026, 6, 12),
|
||||
.state = .acknowledged,
|
||||
} },
|
||||
.{ .note = .{ .line = "Trim by Q3 \u{1F389} - café conviction" } },
|
||||
};
|
||||
var buf: std.Io.Writer.Allocating = .init(a);
|
||||
defer buf.deinit();
|
||||
try buf.writer.print("{f}", .{srf.fmt(JournalRecord, &records, .{})});
|
||||
|
||||
var journal = try parse(a, buf.writer.buffered());
|
||||
defer journal.deinit();
|
||||
try testing.expectEqual(@as(usize, 1), journal.entries.len);
|
||||
try testing.expectEqual(@as(usize, 1), journal.entries[0].notes.len);
|
||||
try testing.expectEqualStrings("Trim by Q3 \u{1F389} - café conviction", journal.entries[0].notes[0]);
|
||||
}
|
||||
|
||||
test "parse: notes attach to the most-recent preceding ack" {
|
||||
const data =
|
||||
\\#!srfv1
|
||||
|
|
|
|||
214
src/tui.zig
214
src/tui.zig
|
|
@ -747,8 +747,7 @@ pub const App = struct {
|
|||
if (mouse.col >= col and mouse.col < col + lbl_len) {
|
||||
if (self.isDisabled(t)) return;
|
||||
self.active_tab = t;
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
self.switchedToTab();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
|
@ -1048,7 +1047,7 @@ pub const App = struct {
|
|||
if (self.input_len > 0) {
|
||||
self.setActiveSymbol(self.input_buf[0..self.input_len]);
|
||||
self.active_tab = .quote;
|
||||
self.loadTabData();
|
||||
self.switchedToTab();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
}
|
||||
self.mode = .normal;
|
||||
|
|
@ -1061,6 +1060,10 @@ pub const App = struct {
|
|||
fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void {
|
||||
// Ctrl+L: full screen redraw (standard TUI convention, not configurable)
|
||||
if (key.codepoint == 'l' and key.mods.ctrl) {
|
||||
// Also drop any status override so the bar returns to
|
||||
// the active tab's default hint - "redraw" should make
|
||||
// the screen pristine, status line included.
|
||||
self.resetStatus();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
|
@ -1100,15 +1103,13 @@ pub const App = struct {
|
|||
},
|
||||
.prev_tab => {
|
||||
self.prevTab();
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
self.switchedToTab();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
},
|
||||
.next_tab => {
|
||||
self.nextTab();
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
self.switchedToTab();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
},
|
||||
|
|
@ -1118,8 +1119,7 @@ pub const App = struct {
|
|||
const target = tabs[idx];
|
||||
if (self.isDisabled(target)) return;
|
||||
self.active_tab = target;
|
||||
self.scroll_offset = 0;
|
||||
self.loadTabData();
|
||||
self.switchedToTab();
|
||||
ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err});
|
||||
return ctx.consumeAndRedraw();
|
||||
}
|
||||
|
|
@ -1374,6 +1374,27 @@ pub const App = struct {
|
|||
self.status_len = len;
|
||||
}
|
||||
|
||||
/// Drop any active status override so the next draw falls back
|
||||
/// to the active tab's default hint (globals + `status_hints`).
|
||||
/// Called on navigation events - tab switches and the Ctrl+L
|
||||
/// redraw - so a stale message set on one screen can't linger
|
||||
/// past the point where it stops being relevant.
|
||||
pub fn resetStatus(self: *App) void {
|
||||
self.status_len = 0;
|
||||
}
|
||||
|
||||
/// Common tail for every "the active tab changed" path: reset
|
||||
/// the viewport scroll, drop any stale status override (so the
|
||||
/// new tab shows its own default hint unless its `activate`
|
||||
/// sets something), and load the new tab's data. Centralized so
|
||||
/// no switch site can forget a step. Callers set `active_tab`
|
||||
/// (directly or via `nextTab`/`prevTab`) before calling this.
|
||||
fn switchedToTab(self: *App) void {
|
||||
self.scroll_offset = 0;
|
||||
self.resetStatus();
|
||||
self.loadTabData();
|
||||
}
|
||||
|
||||
/// Cell pixel size for the active terminal, used by tabs that
|
||||
/// render bitmap charts via the Kitty graphics protocol. Falls
|
||||
/// back to (8, 16) when vaxis hasn't reported pixel dimensions
|
||||
|
|
@ -1677,7 +1698,8 @@ pub const App = struct {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn drawStyledContent(_: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
|
||||
pub fn drawStyledContent(self: *App, _: std.mem.Allocator, buf: []vaxis.Cell, width: u16, height: u16, lines: []const StyledLine) !void {
|
||||
const method = self.gwidthMethod();
|
||||
for (lines, 0..) |line, row| {
|
||||
if (row >= height) break;
|
||||
// Fill row with style bg
|
||||
|
|
@ -1692,10 +1714,41 @@ pub const App = struct {
|
|||
buf[row * width + ci] = .{ .char = .{ .grapheme = graphemes[ci] }, .style = s };
|
||||
}
|
||||
} else {
|
||||
// UTF-8 aware rendering: byte index and column index tracked separately
|
||||
// Grapheme-cluster aware rendering. Segment `line.text`
|
||||
// into grapheme clusters and measure each cluster's
|
||||
// display width with the SAME method vaxis uses at render
|
||||
// time (`caps.unicode`), then set the cell width
|
||||
// explicitly and advance the display column by that width.
|
||||
// That keeps three things in lockstep - our buffer
|
||||
// columns, vaxis's per-cell cursor advance (it steps its
|
||||
// buffer index by the cell's width, skipping the covered
|
||||
// columns), and the terminal - so wide glyphs (CJK and any
|
||||
// emoji: VS16, ZWJ, skin-tone, or flag sequences) don't
|
||||
// desync and smear as the content scrolls. A wide cluster
|
||||
// leaves its trailing column(s) as the row-fill blank;
|
||||
// vaxis never emits those because the prior cell is wide.
|
||||
var col: usize = 0;
|
||||
var bi: usize = 0;
|
||||
while (bi < line.text.len and col < width) {
|
||||
var giter = vaxis.unicode.graphemeIterator(line.text);
|
||||
while (giter.next()) |g| {
|
||||
if (col >= width) break;
|
||||
const cluster = g.bytes(line.text);
|
||||
// Fast path: a single-byte cluster below 0x80 is a
|
||||
// standalone ASCII char (combining marks are
|
||||
// multi-byte, so they'd make the cluster longer) -
|
||||
// always width 1. Skips the gwidth state machine for
|
||||
// the overwhelmingly common case of ASCII table text.
|
||||
const gw: u16 = if (g.len == 1 and cluster[0] < 0x80)
|
||||
1
|
||||
else
|
||||
vaxis.gwidth.gwidth(cluster, method);
|
||||
// Zero-width cluster (e.g. a leading combining mark
|
||||
// with no base): skip it rather than let the next
|
||||
// glyph overwrite this cell or the column desync.
|
||||
if (gw == 0) continue;
|
||||
// A wide cluster that would overrun the row edge is
|
||||
// dropped (matches the prior truncation behavior).
|
||||
if (col + gw > width) break;
|
||||
|
||||
var s = line.style;
|
||||
// `spans` (if present) takes precedence over `alt_*`.
|
||||
// Iterate forward; the LAST span that contains `col`
|
||||
|
|
@ -1723,24 +1776,27 @@ pub const App = struct {
|
|||
} else if (line.alt_style) |alt| {
|
||||
if (col >= line.alt_start and col < line.alt_end) s = alt;
|
||||
}
|
||||
const byte = line.text[bi];
|
||||
if (byte < 0x80) {
|
||||
// ASCII: single byte, single column
|
||||
buf[row * width + col] = .{ .char = .{ .grapheme = ascii_g[byte] }, .style = s };
|
||||
bi += 1;
|
||||
} else {
|
||||
// Multi-byte UTF-8: determine sequence length
|
||||
const seq_len: usize = if (byte >= 0xF0) 4 else if (byte >= 0xE0) 3 else if (byte >= 0xC0) 2 else 1;
|
||||
const end = @min(bi + seq_len, line.text.len);
|
||||
buf[row * width + col] = .{ .char = .{ .grapheme = line.text[bi..end] }, .style = s };
|
||||
bi = end;
|
||||
}
|
||||
col += 1;
|
||||
|
||||
buf[row * width + col] = .{ .char = .{ .grapheme = cluster, .width = @intCast(gw) }, .style = s };
|
||||
col += gw;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The grapheme-width method vaxis will use when it paints this
|
||||
/// frame's cells. Reading it from the live `caps.unicode` (instead
|
||||
/// of hardcoding) is what keeps `drawStyledContent`'s column
|
||||
/// advance in agreement with vaxis's own per-cell measurement: on a
|
||||
/// Unicode-width terminal both count an emoji as 2 columns; on a
|
||||
/// legacy wcwidth terminal both count it the same narrower way.
|
||||
/// Falls back to `.unicode` only before `vx_app` is wired (early
|
||||
/// startup / tests), when nothing is actually being painted.
|
||||
fn gwidthMethod(self: *const App) vaxis.gwidth.Method {
|
||||
const va = self.vx_app orelse return .unicode;
|
||||
return va.vx.caps.unicode;
|
||||
}
|
||||
|
||||
/// Render a prompt + live input buffer + blinking cursor + right-
|
||||
/// aligned hint into the status-bar cell buffer. Shared between
|
||||
/// `.symbol_input` and `.date_input` modes - only the prompt and
|
||||
|
|
@ -2843,6 +2899,112 @@ test "formatStatusHint: single fragment has no separator" {
|
|||
try testing.expectEqualStrings("ctrl+s save", out);
|
||||
}
|
||||
|
||||
test "resetStatus: clears an active status override" {
|
||||
var app: App = undefined;
|
||||
app.setStatus("stuck message");
|
||||
try testing.expect(app.status_len > 0);
|
||||
app.resetStatus();
|
||||
try testing.expectEqual(@as(usize, 0), app.status_len);
|
||||
}
|
||||
|
||||
// ── drawStyledContent: grapheme-width handling ─────────────────
|
||||
|
||||
// vx_app is null in these tests, so `gwidthMethod` falls back to
|
||||
// `.unicode` - the modern terminal interpretation, where emoji are
|
||||
// 2 columns. A real session uses the terminal's detected method.
|
||||
|
||||
test "drawStyledContent: a 2-column emoji occupies one wide cell" {
|
||||
var app: App = undefined;
|
||||
app.vx_app = null;
|
||||
const w: u16 = 8;
|
||||
var buf: [w]vaxis.Cell = undefined;
|
||||
const lines = [_]StyledLine{
|
||||
.{ .text = "a\u{26A0}\u{FE0F}b", .style = .{} }, // a + warning-emoji + b
|
||||
};
|
||||
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
|
||||
|
||||
// col 0: plain ASCII, width 1.
|
||||
try testing.expectEqualStrings("a", buf[0].char.grapheme);
|
||||
try testing.expectEqual(@as(u8, 1), buf[0].char.width);
|
||||
|
||||
// col 1: the whole grapheme cluster (base + FE0F) in one cell,
|
||||
// measured 2 columns wide.
|
||||
try testing.expectEqualStrings("\u{26A0}\u{FE0F}", buf[1].char.grapheme);
|
||||
try testing.expectEqual(@as(u8, 2), buf[1].char.width);
|
||||
|
||||
// col 2: the emoji's second column - left as the row-fill blank;
|
||||
// vaxis skips it because the prior cell reports width 2.
|
||||
try testing.expectEqualStrings(" ", buf[2].char.grapheme);
|
||||
|
||||
// col 3: the next glyph lands after the 2-wide emoji, not at col 2.
|
||||
try testing.expectEqualStrings("b", buf[3].char.grapheme);
|
||||
}
|
||||
|
||||
test "drawStyledContent: a default-presentation emoji (no FE0F) is still 2 wide" {
|
||||
// The whole point of generalizing past the FE0F special-case: a
|
||||
// user-typed emoji like the party popper carries no variation
|
||||
// selector but is still 2 columns. The renderer must measure it.
|
||||
var app: App = undefined;
|
||||
app.vx_app = null;
|
||||
const w: u16 = 8;
|
||||
var buf: [w]vaxis.Cell = undefined;
|
||||
const lines = [_]StyledLine{
|
||||
.{ .text = "x\u{1F389}y", .style = .{} }, // x + party-popper + y
|
||||
};
|
||||
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
|
||||
try testing.expectEqualStrings("x", buf[0].char.grapheme);
|
||||
try testing.expectEqualStrings("\u{1F389}", buf[1].char.grapheme);
|
||||
try testing.expectEqual(@as(u8, 2), buf[1].char.width);
|
||||
try testing.expectEqualStrings(" ", buf[2].char.grapheme); // covered column
|
||||
try testing.expectEqualStrings("y", buf[3].char.grapheme); // after the emoji
|
||||
}
|
||||
|
||||
test "drawStyledContent: a ZWJ emoji sequence is one cluster in one cell" {
|
||||
// Woman astronaut = woman + ZWJ + rocket; a single grapheme
|
||||
// cluster, width 2 under the unicode method.
|
||||
var app: App = undefined;
|
||||
app.vx_app = null;
|
||||
const w: u16 = 8;
|
||||
var buf: [w]vaxis.Cell = undefined;
|
||||
const lines = [_]StyledLine{
|
||||
.{ .text = "\u{1F469}\u{200D}\u{1F680}z", .style = .{} },
|
||||
};
|
||||
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
|
||||
try testing.expectEqualStrings("\u{1F469}\u{200D}\u{1F680}", buf[0].char.grapheme);
|
||||
try testing.expectEqual(@as(u8, 2), buf[0].char.width);
|
||||
try testing.expectEqualStrings("z", buf[2].char.grapheme); // after the 2-wide cluster
|
||||
}
|
||||
|
||||
test "drawStyledContent: a multibyte glyph that is 1 column stays one column" {
|
||||
var app: App = undefined;
|
||||
app.vx_app = null;
|
||||
const w: u16 = 4;
|
||||
var buf: [w]vaxis.Cell = undefined;
|
||||
const lines = [_]StyledLine{
|
||||
.{ .text = "\u{2014}x", .style = .{} }, // em-dash (1 col) + x
|
||||
};
|
||||
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
|
||||
try testing.expectEqualStrings("\u{2014}", buf[0].char.grapheme);
|
||||
try testing.expectEqual(@as(u8, 1), buf[0].char.width);
|
||||
try testing.expectEqualStrings("x", buf[1].char.grapheme);
|
||||
}
|
||||
|
||||
test "drawStyledContent: a wide emoji with no room at the edge is dropped" {
|
||||
// width 2: 'a' takes col 0, leaving only col 1 - not enough room
|
||||
// for a 2-wide cell, so the emoji is truncated at the edge and
|
||||
// col 1 stays the row-fill blank.
|
||||
var app: App = undefined;
|
||||
app.vx_app = null;
|
||||
const w: u16 = 2;
|
||||
var buf: [w]vaxis.Cell = undefined;
|
||||
const lines = [_]StyledLine{
|
||||
.{ .text = "a\u{26A0}\u{FE0F}", .style = .{} },
|
||||
};
|
||||
try app.drawStyledContent(undefined, &buf, w, 1, &lines);
|
||||
try testing.expectEqualStrings("a", buf[0].char.grapheme);
|
||||
try testing.expectEqualStrings(" ", buf[1].char.grapheme);
|
||||
}
|
||||
|
||||
// ── symbol toggle helpers ─────────────────────────────────────
|
||||
|
||||
test "shouldStashSymbol: stashes a differing non-empty symbol" {
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result {
|
|||
return .committed;
|
||||
}
|
||||
if (key.codepoint == vaxis.Key.backspace) {
|
||||
if (len.* > 0) len.* -= 1;
|
||||
len.* = graphemeBackspaceLen(buf[0..len.*]);
|
||||
return .edited;
|
||||
}
|
||||
// Ctrl+U: clear entire input (readline convention)
|
||||
|
|
@ -80,12 +80,9 @@ pub fn handleKey(buf: []u8, len: *usize, key: vaxis.Key) Result {
|
|||
len.* = 0;
|
||||
return .edited;
|
||||
}
|
||||
// Accept printable ASCII (letters, digits, common punctuation).
|
||||
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) {
|
||||
buf[len.*] = @intCast(key.codepoint);
|
||||
len.* += 1;
|
||||
return .edited;
|
||||
}
|
||||
// Printable input. Prefer the terminal-resolved text so Shift /
|
||||
// Caps Lock produce the right case (see `appendPrintable`).
|
||||
if (appendPrintable(buf, len, key)) return .edited;
|
||||
return .ignored;
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +120,8 @@ pub const MultiResult = enum {
|
|||
/// => `.committed`. `len.*` unchanged so the caller can flush
|
||||
/// any final unfinished fragment before joining all fragments
|
||||
/// and writing the journal record.
|
||||
/// - **Backspace, Ctrl+U, printable ASCII**: same as `handleKey`.
|
||||
/// - **Backspace** (deletes the last grapheme cluster), **Ctrl+U**,
|
||||
/// and printable text input: same as `handleKey`.
|
||||
pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult {
|
||||
if (key.codepoint == vaxis.Key.escape) {
|
||||
len.* = 0;
|
||||
|
|
@ -148,19 +146,70 @@ pub fn handleKeyMulti(buf: []u8, len: *usize, key: vaxis.Key) MultiResult {
|
|||
return .fragment;
|
||||
}
|
||||
if (key.codepoint == vaxis.Key.backspace) {
|
||||
if (len.* > 0) len.* -= 1;
|
||||
len.* = graphemeBackspaceLen(buf[0..len.*]);
|
||||
return .edited;
|
||||
}
|
||||
if (key.matches('u', .{ .ctrl = true })) {
|
||||
len.* = 0;
|
||||
return .edited;
|
||||
}
|
||||
if (appendPrintable(buf, len, key)) return .edited;
|
||||
return .ignored;
|
||||
}
|
||||
|
||||
/// Append the character a key produced to `buf`, returning true on
|
||||
/// success. Callers handle the special keys (Esc, Enter, Backspace,
|
||||
/// Ctrl+*) before this runs, so anything reaching here is ordinary
|
||||
/// text input.
|
||||
///
|
||||
/// We prefer `key.text` - the terminal's resolved text for the
|
||||
/// event - over the raw codepoint. Under the Kitty keyboard protocol
|
||||
/// (which vaxis turns on), a Shift+a press reports `codepoint = 'a'`
|
||||
/// (the base-layout key) with `text = "A"`; keying off the codepoint
|
||||
/// alone silently downcases everything and turns shifted symbols
|
||||
/// (`!@#`) back into their digits. We take a single printable-ASCII
|
||||
/// byte or any well-formed multi-byte UTF-8 grapheme (accented
|
||||
/// letters, CJK, emoji), rejecting lone control bytes - so notes can
|
||||
/// hold whatever the user types or pastes. When no text is reported
|
||||
/// (legacy terminal, no Kitty protocol) we fall back to the codepoint,
|
||||
/// which carries the shifted value in that mode (ASCII only).
|
||||
fn appendPrintable(buf: []u8, len: *usize, key: vaxis.Key) bool {
|
||||
if (key.text) |text| {
|
||||
// Accept the terminal-resolved text when it's typed content: a
|
||||
// single printable ASCII byte, or any well-formed multi-byte
|
||||
// UTF-8 grapheme (accented letters, CJK, emoji - including
|
||||
// pasted ones, which arrive as key events under bracketed
|
||||
// paste). Reject a lone control byte and anything that won't
|
||||
// fit. When text is present we never fall through to the
|
||||
// codepoint path - that would re-append a downcased/duplicate
|
||||
// byte.
|
||||
if (text.len == 0) return false;
|
||||
if (text.len == 1 and !std.ascii.isPrint(text[0])) return false;
|
||||
if (text.len > 1 and !std.unicode.utf8ValidateSlice(text)) return false;
|
||||
if (len.* + text.len > buf.len) return false;
|
||||
@memcpy(buf[len.*..][0..text.len], text);
|
||||
len.* += text.len;
|
||||
return true;
|
||||
}
|
||||
if (key.codepoint < std.math.maxInt(u7) and std.ascii.isPrint(@intCast(key.codepoint)) and len.* < buf.len) {
|
||||
buf[len.*] = @intCast(key.codepoint);
|
||||
len.* += 1;
|
||||
return .edited;
|
||||
return true;
|
||||
}
|
||||
return .ignored;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// New buffer length after deleting the final grapheme cluster - the
|
||||
/// result of one Backspace. Cluster-aware so backspacing an emoji
|
||||
/// (which can be several codepoints: ZWJ joins, skin-tone modifiers,
|
||||
/// a trailing FE0F) removes the whole glyph instead of leaving a
|
||||
/// mangled partial UTF-8 sequence behind. For pure ASCII this removes
|
||||
/// exactly one byte.
|
||||
fn graphemeBackspaceLen(text: []const u8) usize {
|
||||
var iter = vaxis.unicode.graphemeIterator(text);
|
||||
var last_start: usize = 0;
|
||||
while (iter.next()) |g| last_start = g.start;
|
||||
return last_start;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────
|
||||
|
|
@ -194,14 +243,93 @@ test "handleKey: printable ASCII appends and increments len" {
|
|||
try testing.expectEqual(@as(u8, 'x'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKey: Shift+letter appends the uppercase text, not the base codepoint" {
|
||||
// Kitty keyboard protocol reports the base-layout key in
|
||||
// `codepoint` and the resolved character in `text`. Shift+a must
|
||||
// land 'A', not 'a'.
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(usize, 1), len);
|
||||
try testing.expectEqual(@as(u8, 'A'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKey: shifted symbol appends the symbol, not the digit" {
|
||||
// Shift+1 => '!' : codepoint stays '1', text is "!".
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = '1', .text = "!", .mods = .{ .shift = true } });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(u8, '!'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKey: legacy terminal (no text) falls back to the codepoint" {
|
||||
// Without the Kitty protocol, vaxis reports the shifted value
|
||||
// directly in `codepoint` and leaves `text` null.
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = 'A' });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(u8, 'A'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKey: a multibyte UTF-8 grapheme is appended whole" {
|
||||
// Em-dash (3 bytes). Multi-byte text is accepted now; the whole
|
||||
// grapheme lands in the buffer.
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = 0x2014, .text = "\u{2014}" });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(usize, 3), len);
|
||||
try testing.expectEqualStrings("\u{2014}", buf[0..len]);
|
||||
}
|
||||
|
||||
test "handleKey: an emoji grapheme is appended whole" {
|
||||
// Party popper, 4 bytes.
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = 0x1F389, .text = "\u{1F389}" });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(usize, 4), len);
|
||||
try testing.expectEqualStrings("\u{1F389}", buf[0..len]);
|
||||
}
|
||||
|
||||
test "handleKey: a lone control byte in text is rejected" {
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = vaxis.Key.tab, .text = "\t" }));
|
||||
try testing.expectEqual(@as(usize, 0), len);
|
||||
}
|
||||
|
||||
test "handleKey: multibyte append respects buffer capacity" {
|
||||
// 4-byte emoji into a 3-byte buffer: doesn't fit, nothing appended.
|
||||
var buf: [3]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
try testing.expectEqual(Result.ignored, handleKey(&buf, &len, .{ .codepoint = 0x1F389, .text = "\u{1F389}" }));
|
||||
try testing.expectEqual(@as(usize, 0), len);
|
||||
}
|
||||
|
||||
test "handleKey: backspace decrements len" {
|
||||
var buf: [16]u8 = undefined;
|
||||
@memcpy(buf[0..3], "abc");
|
||||
var len: usize = 3;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(usize, 2), len);
|
||||
}
|
||||
|
||||
test "handleKey: backspace removes a whole emoji grapheme cluster" {
|
||||
// Wave + skin-tone modifier is one cluster (8 bytes); backspace
|
||||
// must remove all of it, not leave a mangled partial sequence.
|
||||
var buf: [16]u8 = undefined;
|
||||
@memcpy(buf[0..8], "\u{1F44B}\u{1F3FF}");
|
||||
var len: usize = 8;
|
||||
const result = handleKey(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
|
||||
try testing.expectEqual(Result.edited, result);
|
||||
try testing.expectEqual(@as(usize, 0), len);
|
||||
}
|
||||
|
||||
test "handleKey: backspace at len=0 stays at 0" {
|
||||
var buf: [16]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
|
|
@ -296,14 +424,44 @@ test "handleKeyMulti: printable ASCII appends" {
|
|||
try testing.expectEqual(@as(u8, 'x'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKeyMulti: Shift+letter appends uppercase text (the ack-note bug)" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
|
||||
try testing.expectEqual(MultiResult.edited, result);
|
||||
try testing.expectEqual(@as(u8, 'A'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKeyMulti: legacy terminal (no text) falls back to the codepoint" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
const result = handleKeyMulti(&buf, &len, .{ .codepoint = 'Z' });
|
||||
try testing.expectEqual(MultiResult.edited, result);
|
||||
try testing.expectEqual(@as(u8, 'Z'), buf[0]);
|
||||
}
|
||||
|
||||
test "handleKeyMulti: backspace decrements len" {
|
||||
var buf: [64]u8 = undefined;
|
||||
@memcpy(buf[0..3], "abc");
|
||||
var len: usize = 3;
|
||||
const result = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
|
||||
try testing.expectEqual(MultiResult.edited, result);
|
||||
try testing.expectEqual(@as(usize, 2), len);
|
||||
}
|
||||
|
||||
test "handleKeyMulti: Shift+letter and an emoji both append (notes accept any grapheme)" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
// Shift+a -> "A"
|
||||
_ = handleKeyMulti(&buf, &len, .{ .codepoint = 'a', .text = "A", .mods = .{ .shift = true } });
|
||||
// a multi-codepoint emoji (woman astronaut: woman + ZWJ + rocket)
|
||||
_ = handleKeyMulti(&buf, &len, .{ .codepoint = 0x1F469, .text = "\u{1F469}\u{200D}\u{1F680}" });
|
||||
try testing.expectEqualStrings("A\u{1F469}\u{200D}\u{1F680}", buf[0..len]);
|
||||
// Backspace removes the whole emoji cluster, leaving just "A".
|
||||
_ = handleKeyMulti(&buf, &len, .{ .codepoint = vaxis.Key.backspace });
|
||||
try testing.expectEqualStrings("A", buf[0..len]);
|
||||
}
|
||||
|
||||
test "handleKeyMulti: ctrl+U clears buffer" {
|
||||
var buf: [64]u8 = undefined;
|
||||
var len: usize = 5;
|
||||
|
|
|
|||
|
|
@ -269,6 +269,22 @@ pub const tab = struct {
|
|||
|
||||
pub const deactivate = framework.noopDeactivate(State);
|
||||
|
||||
/// Status-bar override. While collecting an ack note, render the
|
||||
/// note-entry key hints full-width; otherwise the App-level
|
||||
/// default status applies. Driving the prompt off `input_mode`
|
||||
/// (instead of a one-shot `setStatus`) means it appears exactly
|
||||
/// when the modal is open and the App restores the tab's default
|
||||
/// hint the instant we leave it - no frozen prompt after Esc or
|
||||
/// commit. It also marks the tab modal, so a stray tab-bar click
|
||||
/// can't switch tabs out from under a half-typed note.
|
||||
pub fn statusOverride(state: *State, app: *App) ?framework.StatusOverride {
|
||||
_ = app;
|
||||
return switch (state.input_mode) {
|
||||
.normal => null,
|
||||
.ack_note => .{ .hint = "Type reasoning. Enter = next line. Ctrl+Enter/Ctrl+D = save. Esc = cancel." },
|
||||
};
|
||||
}
|
||||
|
||||
/// Framework poll-tick hook: true while the observation panel
|
||||
/// has async checks in flight. Drives the App's poll timer so
|
||||
/// the `tick` hook below gets called without user input. The
|
||||
|
|
@ -416,6 +432,11 @@ pub const tab = struct {
|
|||
/// Wheel events fall through to App's scroll handling via
|
||||
/// `onWheelMove` returning false (see below).
|
||||
pub fn handleMouse(state: *State, app: *App, mouse: vaxis.Mouse) bool {
|
||||
// While collecting an ack note the tab is modal (statusOverride
|
||||
// is non-null, so the App routes every mouse event here).
|
||||
// Swallow them all: a click that moved the cursor mid-note
|
||||
// would make commitAckNote write to a different finding.
|
||||
if (state.input_mode == .ack_note) return true;
|
||||
if (mouse.button != .left) return false;
|
||||
if (mouse.type != .press) return false;
|
||||
const view = state.view orelse return false;
|
||||
|
|
@ -542,6 +563,9 @@ pub const tab = struct {
|
|||
return true;
|
||||
};
|
||||
state.note_len = 0;
|
||||
// The bar just grew by a line; keep its bottom on
|
||||
// screen so the next line the user types is visible.
|
||||
ensureAckInputVisible(state, app);
|
||||
return true;
|
||||
},
|
||||
.committed => {
|
||||
|
|
@ -667,15 +691,37 @@ fn wrapCursor(current: usize, delta: isize, total: usize) usize {
|
|||
/// (acceptable - the cursor IS at row zero anyway).
|
||||
fn ensureCursorVisible(state: *const State, scroll_offset: *usize, visible_height: usize) void {
|
||||
const visual = cursorVisualRow(state) orelse return;
|
||||
if (visual < scroll_offset.*) {
|
||||
scroll_offset.* = visual;
|
||||
return;
|
||||
}
|
||||
if (visible_height > 0 and visual >= scroll_offset.* + visible_height) {
|
||||
scroll_offset.* = visual - visible_height + 1;
|
||||
ensureRowVisible(visual, scroll_offset, visible_height);
|
||||
}
|
||||
|
||||
/// Scroll `scroll_offset` the minimum amount so content row
|
||||
/// `target` lands inside the `[scroll_offset, scroll_offset +
|
||||
/// visible_height)` viewport. Pure over the offset pointer;
|
||||
/// shared by the cursor-follow path and the ack-input-follow path.
|
||||
fn ensureRowVisible(target: usize, scroll_offset: *usize, visible_height: usize) void {
|
||||
if (target < scroll_offset.*) {
|
||||
scroll_offset.* = target;
|
||||
} else if (visible_height > 0 and target >= scroll_offset.* + visible_height) {
|
||||
scroll_offset.* = target - visible_height + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// While the inline note-input bar is open, keep its bottom line
|
||||
/// (the hint row, below the in-progress input line) on screen.
|
||||
/// Without this, acknowledging a finding that sits on the last
|
||||
/// visible row opens the whole input bar below the viewport and
|
||||
/// the user types blind. Called when entering ack mode and after
|
||||
/// each committed fragment (which grows the bar by one line).
|
||||
fn ensureAckInputVisible(state: *State, app: *App) void {
|
||||
if (state.input_mode != .ack_note) return;
|
||||
const cv = cursorVisualRow(state) orelse return;
|
||||
const fv = state.findings_view orelse return;
|
||||
const local = cursorLocalIndex(state);
|
||||
if (local >= fv.rows.len) return;
|
||||
const bottom = cv + expansionLineCount(fv.rows[local]) + inputBarLineCount(state);
|
||||
ensureRowVisible(bottom, &app.scroll_offset, app.visible_height);
|
||||
}
|
||||
|
||||
/// Compute the cursor's content-area row index (0 = first
|
||||
/// rendered line). Returns null when neither view is loaded or
|
||||
/// when the section indices haven't been populated yet (first
|
||||
|
|
@ -765,7 +811,13 @@ fn ackCurrentFinding(state: *State, app: *App) void {
|
|||
// input mode from `.normal`, and `commitAckNote`/`cancelAckNote`
|
||||
// both clear it on exit). Defensive: clear anyway.
|
||||
clearNoteFragments(state, app.allocator);
|
||||
app.setStatus("Type reasoning. Enter = next line. Ctrl+Enter = save. Esc = cancel.");
|
||||
// No setStatus: the `statusOverride` hook renders the input
|
||||
// prompt while `input_mode == .ack_note` and the App restores
|
||||
// the tab's default hint automatically when we exit the mode
|
||||
// (on Esc or commit) - no stale prompt left frozen on the bar.
|
||||
// Scroll so the freshly-opened input bar is actually on screen
|
||||
// even when the finding sat on the last visible row.
|
||||
ensureAckInputVisible(state, app);
|
||||
}
|
||||
|
||||
/// Un-acknowledge the cursor-selected finding. Flips the journal
|
||||
|
|
@ -876,7 +928,9 @@ fn cancelAckNote(state: *State, app: *App) void {
|
|||
state.input_mode = .normal;
|
||||
state.note_len = 0;
|
||||
clearNoteFragments(state, app.allocator);
|
||||
// No setStatus: the disappearing input bar is its own feedback.
|
||||
// No setStatus needed: flipping `input_mode` back to `.normal`
|
||||
// makes `statusOverride` return null, so the App falls straight
|
||||
// back to the tab's default hint - the bar visibly resets.
|
||||
}
|
||||
|
||||
/// Commit the in-progress ack note: dupe any final unflushed
|
||||
|
|
@ -943,10 +997,11 @@ fn commitAckNote(state: *State, app: *App) void {
|
|||
};
|
||||
|
||||
rebuildFindingsView(state, app);
|
||||
// No setStatus on success: the visible removal of the row
|
||||
// from the findings list (or the "[acked]" prefix when
|
||||
// show_acked is on) is feedback enough. Leaving the prior
|
||||
// help/hint visible is the user's preference.
|
||||
// No setStatus on success: the `defer` above flips `input_mode`
|
||||
// back to `.normal`, so `statusOverride` returns null and the
|
||||
// bar falls back to the tab's default hint. The row leaving the
|
||||
// findings list (or gaining an "[acked]" prefix when show_acked
|
||||
// is on) is the confirmation.
|
||||
}
|
||||
|
||||
// ── Data loading ──────────────────────────────────────────────
|
||||
|
|
@ -1416,19 +1471,14 @@ pub fn buildStyledLines(state: *State, app: *App, arena: std.mem.Allocator) ![]c
|
|||
return lines.toOwnedSlice(arena);
|
||||
}
|
||||
|
||||
/// Glyph used in the findings table's severity column. Each is two
|
||||
/// display columns wide (emoji-presentation) so renderers don't have
|
||||
/// to width-correct.
|
||||
/// Per-finding-row glyph indicating severity. Each glyph string
|
||||
/// includes a trailing U+FE0F variation selector to force emoji
|
||||
/// presentation. The variation selector also serves a critical
|
||||
/// rendering role here: `drawStyledContent` allocates one buffer
|
||||
/// cell per UTF-8 sequence (advancing col by 1), so a single-
|
||||
/// codepoint emoji like ❌ takes 1 buffer cell while terminals
|
||||
/// render it as 2 visual cols, desyncing the col counter from
|
||||
/// terminal display. Appending FE0F gives it a second codepoint
|
||||
/// (which gets its own cell) so buffer-col advancement matches
|
||||
/// terminal-col advancement at exactly 2 per emoji.
|
||||
/// (two-display-column) presentation - without it some terminals
|
||||
/// draw a narrow text-style glyph. The renderer (`drawStyledContent`)
|
||||
/// measures every grapheme's width with `gwidth` and sizes the cell
|
||||
/// accordingly, so it handles these correctly along with any other
|
||||
/// wide glyph; the FE0F here is purely about presentation, not a
|
||||
/// renderer hint.
|
||||
fn severityGlyph(sev: observations.Severity) []const u8 {
|
||||
return switch (sev) {
|
||||
.warn => "⚠️", // U+26A0 + FE0F
|
||||
|
|
@ -1447,9 +1497,8 @@ fn severityGlyph(sev: observations.Severity) []const u8 {
|
|||
/// in the renderer means the renderer is ready when the async
|
||||
/// path lands.
|
||||
/// Per-check status-grid glyph. See `severityGlyph` for the
|
||||
/// FE0F-trailing convention - it forces emoji presentation and
|
||||
/// gives the renderer a second cell to track so buffer-col
|
||||
/// advancement matches terminal-col advancement.
|
||||
/// FE0F-trailing convention (emoji presentation; the renderer
|
||||
/// measures width generically via gwidth).
|
||||
fn checkStatusGlyph(result: observations.CheckResult) []const u8 {
|
||||
return switch (result) {
|
||||
.pass => "✅\u{FE0F}",
|
||||
|
|
@ -2916,6 +2965,95 @@ test "onCursorMove: empty table returns false" {
|
|||
state.findings_view = null;
|
||||
}
|
||||
|
||||
// ── ensureRowVisible / ack-input scroll-into-view ──────────────
|
||||
|
||||
test "ensureRowVisible: scrolls down so a below-viewport row is last visible" {
|
||||
var off: usize = 0;
|
||||
ensureRowVisible(23, &off, 10);
|
||||
try testing.expectEqual(@as(usize, 14), off); // 23 - 10 + 1
|
||||
}
|
||||
|
||||
test "ensureRowVisible: scrolls up so an above-viewport row is first visible" {
|
||||
var off: usize = 30;
|
||||
ensureRowVisible(5, &off, 10);
|
||||
try testing.expectEqual(@as(usize, 5), off);
|
||||
}
|
||||
|
||||
test "ensureRowVisible: no change when the row is already on screen" {
|
||||
var off: usize = 10;
|
||||
ensureRowVisible(12, &off, 10);
|
||||
try testing.expectEqual(@as(usize, 10), off);
|
||||
}
|
||||
|
||||
test "ensureAckInputVisible: pulls a bottom-row ack input bar onto screen" {
|
||||
// One finding, no holdings -> unified cursor 0 is that finding.
|
||||
// Pretend it renders at content row 20 (findings_first_row) and
|
||||
// the viewport is only 10 rows tall starting at offset 0, so the
|
||||
// freshly-opened input bar (detail + in-progress + hint = 3 rows
|
||||
// below the finding) sits entirely below the fold.
|
||||
var findings = [_]observations_view.FindingRow{
|
||||
.{ .severity = .warn, .kind = "k", .target = "t", .text = "x", .is_acked = false },
|
||||
};
|
||||
const empty_view: review_view.ReviewView = .{
|
||||
.rows = &.{},
|
||||
.totals = std.mem.zeroes(review_view.ReviewTotals),
|
||||
.as_of = zfin.Date.fromYmd(2026, 6, 8),
|
||||
.total_liquid = 0,
|
||||
.portfolio_path = "x",
|
||||
};
|
||||
var state: State = .{
|
||||
.view = empty_view,
|
||||
.cursor = 0,
|
||||
.expanded_finding = 0,
|
||||
.input_mode = .ack_note,
|
||||
.findings_first_row = 20,
|
||||
.findings_view = .{
|
||||
.rows = &findings,
|
||||
.total_active = 1,
|
||||
.total_acked = 0,
|
||||
.total_resolved = 0,
|
||||
},
|
||||
};
|
||||
var app: App = undefined;
|
||||
app.scroll_offset = 0;
|
||||
app.visible_height = 10;
|
||||
|
||||
ensureAckInputVisible(&state, &app);
|
||||
// finding at row 20; bottom of the bar at 20 + 1 + 2 = 23; the
|
||||
// offset advances so row 23 is the last visible: 23 - 10 + 1 = 14.
|
||||
try testing.expectEqual(@as(usize, 14), app.scroll_offset);
|
||||
|
||||
state.view = null;
|
||||
state.findings_view = null;
|
||||
}
|
||||
|
||||
test "ensureAckInputVisible: no-op when not collecting a note" {
|
||||
var state: State = .{ .input_mode = .normal, .findings_first_row = 20 };
|
||||
var app: App = undefined;
|
||||
app.scroll_offset = 7;
|
||||
app.visible_height = 10;
|
||||
ensureAckInputVisible(&state, &app);
|
||||
try testing.expectEqual(@as(usize, 7), app.scroll_offset);
|
||||
}
|
||||
|
||||
// ── statusOverride (ack-note prompt) ───────────────────────────
|
||||
|
||||
test "statusOverride: null in normal mode, key hints while collecting a note" {
|
||||
var state: State = .{};
|
||||
var app: App = undefined;
|
||||
try testing.expect(tab.statusOverride(&state, &app) == null);
|
||||
|
||||
state.input_mode = .ack_note;
|
||||
const ov = tab.statusOverride(&state, &app) orelse return error.TestUnexpectedResult;
|
||||
switch (ov) {
|
||||
.hint => |hint| {
|
||||
try testing.expect(std.mem.indexOf(u8, hint, "Ctrl+Enter") != null);
|
||||
try testing.expect(std.mem.indexOf(u8, hint, "Esc") != null);
|
||||
},
|
||||
else => return error.TestUnexpectedResult,
|
||||
}
|
||||
}
|
||||
|
||||
test "onCursorMove: wheel-sized delta still moves by one row" {
|
||||
// Wheel events arrive as ±3 per detent. The cursor should
|
||||
// step by ±1 regardless - wheel == j/k, not "skip three rows".
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue