Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
620 lines
23 KiB
Rust
620 lines
23 KiB
Rust
//! Box-drawing table grid detection so selection inside rendered tables
|
|
//! operates on cells; anything `detect` can't prove falls back to linear.
|
|
//! Table lines never soft-wrap, so one rendered line is one block line.
|
|
|
|
use std::ops::Range;
|
|
use unicode_segmentation::UnicodeSegmentation;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
/// A cell position within a detected grid: `row` indexes logical rows
|
|
/// (header = 0), `col` indexes columns left to right.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct CellRef {
|
|
pub row: usize,
|
|
pub col: usize,
|
|
}
|
|
|
|
/// Geometry of one box-drawing table, in the block's line/column space:
|
|
/// line indices are `block_line_idx` values, columns are display columns in
|
|
/// the same space as `RangeHit::col_within_range`.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct TableGeometry {
|
|
/// Full extent of the grid, top border line ..= bottom border line
|
|
/// (half-open).
|
|
line_range: Range<usize>,
|
|
/// Display columns of the vertical grid lines, ascending.
|
|
/// `junction_cols.len() == column count + 1`.
|
|
junction_cols: Vec<u16>,
|
|
/// Per logical row, the contiguous block-line range of its content lines
|
|
/// (a row wrapped inside cells spans several lines). Never empty.
|
|
rows: Vec<Range<usize>>,
|
|
}
|
|
|
|
/// Border-row family, keyed by its corner/junction glyphs.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum BorderKind {
|
|
/// `┌──┬──┐`
|
|
Top,
|
|
/// `├──┼──┤`
|
|
Divider,
|
|
/// `└──┴──┘`
|
|
Bottom,
|
|
}
|
|
|
|
/// One line classified against (or independent of) a grid context.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum GridLine {
|
|
Border {
|
|
junctions: Vec<u16>,
|
|
kind: BorderKind,
|
|
},
|
|
Content,
|
|
Other,
|
|
}
|
|
|
|
const BAR: char = '\u{2502}'; // │
|
|
|
|
/// Chars permitted before a grid's left edge: indentation and blockquote
|
|
/// bars (`│ `-prefixed tables render inside quotes with fully selectable
|
|
/// text — see `QuoteBarStrip`).
|
|
fn is_prefix_char(c: char) -> bool {
|
|
c == ' ' || c == BAR
|
|
}
|
|
|
|
/// (display column, first char) for every grapheme in `text`, mirroring the
|
|
/// column arithmetic of `slice_display_cols` / `word_boundaries_at_col`.
|
|
fn grapheme_cols(text: &str) -> impl Iterator<Item = (u16, char)> + '_ {
|
|
let mut col = 0u16;
|
|
text.graphemes(true).filter_map(move |g| {
|
|
let width = UnicodeWidthStr::width(g) as u16;
|
|
if width == 0 {
|
|
return None;
|
|
}
|
|
let at = col;
|
|
col = col.saturating_add(width);
|
|
Some((at, g.chars().next().unwrap_or(' ')))
|
|
})
|
|
}
|
|
|
|
/// Parse a border row (`┌──┬──┐` / `├──┼──┤` / `└──┴──┘`), tolerating an
|
|
/// indentation/blockquote prefix. Returns the junction columns (corners
|
|
/// included) and the row family, or `None` when the line is not a border row.
|
|
fn parse_border_row(text: &str) -> Option<(Vec<u16>, BorderKind)> {
|
|
let (kind, mid, close) = ('\u{250C}', '\u{252C}', '\u{2510}'); // ┌ ┬ ┐
|
|
let (dkind, dmid, dclose) = ('\u{251C}', '\u{253C}', '\u{2524}'); // ├ ┼ ┤
|
|
let (bkind, bmid, bclose) = ('\u{2514}', '\u{2534}', '\u{2518}'); // └ ┴ ┘
|
|
const H: char = '\u{2500}'; // ─
|
|
|
|
let mut junctions: Vec<u16> = Vec::new();
|
|
let mut family: Option<BorderKind> = None;
|
|
let mut closed = false;
|
|
|
|
for (col, c) in grapheme_cols(text) {
|
|
match family {
|
|
None => {
|
|
// Still in the optional prefix; the first corner glyph opens
|
|
// the grid and fixes the family.
|
|
let f = match c {
|
|
_ if c == kind => Some(BorderKind::Top),
|
|
_ if c == dkind => Some(BorderKind::Divider),
|
|
_ if c == bkind => Some(BorderKind::Bottom),
|
|
_ if is_prefix_char(c) => None,
|
|
_ => return None,
|
|
};
|
|
if let Some(f) = f {
|
|
family = Some(f);
|
|
junctions.push(col);
|
|
}
|
|
}
|
|
Some(f) => {
|
|
if closed {
|
|
// Trailing content after the closing corner: not a grid row.
|
|
return None;
|
|
}
|
|
let (m, cl) = match f {
|
|
BorderKind::Top => (mid, close),
|
|
BorderKind::Divider => (dmid, dclose),
|
|
BorderKind::Bottom => (bmid, bclose),
|
|
};
|
|
if c == m {
|
|
junctions.push(col);
|
|
} else if c == cl {
|
|
junctions.push(col);
|
|
closed = true;
|
|
} else if c != H {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A grid needs at least two junctions (one column) and a closing corner.
|
|
if !closed || junctions.len() < 2 {
|
|
return None;
|
|
}
|
|
Some((junctions, family.expect("closed implies family")))
|
|
}
|
|
|
|
/// Whether `text` is a content row of a grid with the given junction set:
|
|
/// a `│` at every junction column, nothing but prefix chars before the left
|
|
/// edge, and nothing after the right edge (selection text is end-trimmed).
|
|
fn is_content_row(text: &str, junctions: &[u16]) -> bool {
|
|
let (Some(&left), Some(&right)) = (junctions.first(), junctions.last()) else {
|
|
return false;
|
|
};
|
|
let mut needed = junctions.iter().peekable();
|
|
let mut last_col = 0u16;
|
|
for (col, c) in grapheme_cols(text) {
|
|
last_col = col;
|
|
if col < left && !is_prefix_char(c) {
|
|
return false;
|
|
}
|
|
if col > right {
|
|
return false;
|
|
}
|
|
if needed.peek() == Some(&&col) {
|
|
if c != BAR {
|
|
return false;
|
|
}
|
|
needed.next();
|
|
}
|
|
}
|
|
needed.peek().is_none() && last_col == right
|
|
}
|
|
|
|
/// Classify one line against a known junction set.
|
|
fn classify(text: &str, junctions: &[u16]) -> GridLine {
|
|
if let Some((j, kind)) = parse_border_row(text) {
|
|
if j == junctions {
|
|
return GridLine::Border { junctions: j, kind };
|
|
}
|
|
return GridLine::Other;
|
|
}
|
|
if is_content_row(text, junctions) {
|
|
return GridLine::Content;
|
|
}
|
|
GridLine::Other
|
|
}
|
|
|
|
impl TableGeometry {
|
|
/// Detect the grid containing `at_line`, reading lines through
|
|
/// `text_at`. `None` unless `at_line` sits inside a fully-enclosed,
|
|
/// column-consistent grid — callers then fall back to linear.
|
|
pub fn detect(text_at: impl Fn(usize) -> Option<String>, at_line: usize) -> Option<Self> {
|
|
// The anchor line itself must be part of a grid; its border row (or,
|
|
// for content rows, the nearest border row above) fixes the junction
|
|
// set every other line is validated against.
|
|
let anchor_text = text_at(at_line)?;
|
|
let junctions: Vec<u16> = if let Some((j, _)) = parse_border_row(&anchor_text) {
|
|
j
|
|
} else {
|
|
// Walk up to the nearest border row to fix the junction set.
|
|
// Capped: a real anchor's border is at most one wrapped row
|
|
// above; a long walk means prefix-led prose, not a table.
|
|
const MAX_JUNCTION_SEARCH: usize = 400;
|
|
let mut found: Option<Vec<u16>> = None;
|
|
let mut line = at_line;
|
|
while line > 0 && at_line - line < MAX_JUNCTION_SEARCH {
|
|
line -= 1;
|
|
let Some(text) = text_at(line) else { break };
|
|
if let Some((j, _)) = parse_border_row(&text) {
|
|
found = Some(j);
|
|
break;
|
|
}
|
|
// Cheap plausibility gate so we don't scan a whole prose
|
|
// block: rows of a grid always start with a prefix char.
|
|
if !text.chars().next().is_some_and(is_prefix_char) {
|
|
break;
|
|
}
|
|
}
|
|
found?
|
|
};
|
|
|
|
// Validate outward: walk up to the top border, down to the bottom
|
|
// border, requiring every line in between to belong to the grid.
|
|
let mut top = at_line;
|
|
loop {
|
|
let text = text_at(top)?;
|
|
match classify(&text, &junctions) {
|
|
GridLine::Border {
|
|
kind: BorderKind::Top,
|
|
..
|
|
} => break,
|
|
// Hitting a bottom border strictly above `at_line` means
|
|
// `at_line` was below the grid, not inside it. (`at_line`
|
|
// itself may be the bottom border.)
|
|
GridLine::Border {
|
|
kind: BorderKind::Bottom,
|
|
..
|
|
} if top < at_line => return None,
|
|
GridLine::Border { .. } | GridLine::Content if top > 0 => top -= 1,
|
|
_ => return None,
|
|
}
|
|
}
|
|
let mut bottom = at_line;
|
|
loop {
|
|
let text = text_at(bottom)?;
|
|
match classify(&text, &junctions) {
|
|
GridLine::Border {
|
|
kind: BorderKind::Bottom,
|
|
..
|
|
} => break,
|
|
GridLine::Border {
|
|
kind: BorderKind::Top,
|
|
..
|
|
} if bottom > at_line => return None,
|
|
GridLine::Border { .. } | GridLine::Content => bottom += 1,
|
|
GridLine::Other => return None,
|
|
}
|
|
}
|
|
|
|
// Logical rows: contiguous content-line runs between border rows.
|
|
let mut rows: Vec<Range<usize>> = Vec::new();
|
|
let mut run_start: Option<usize> = None;
|
|
for line in top..=bottom {
|
|
let text = text_at(line)?;
|
|
match classify(&text, &junctions) {
|
|
GridLine::Content => {
|
|
run_start.get_or_insert(line);
|
|
}
|
|
GridLine::Border { .. } => {
|
|
if let Some(start) = run_start.take() {
|
|
rows.push(start..line);
|
|
}
|
|
}
|
|
GridLine::Other => return None,
|
|
}
|
|
}
|
|
if rows.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
Some(Self {
|
|
line_range: top..bottom + 1,
|
|
junction_cols: junctions,
|
|
rows,
|
|
})
|
|
}
|
|
|
|
/// Full grid extent (top border ..= bottom border, half-open).
|
|
pub fn line_range(&self) -> Range<usize> {
|
|
self.line_range.clone()
|
|
}
|
|
|
|
pub fn n_cols(&self) -> usize {
|
|
self.junction_cols.len() - 1
|
|
}
|
|
|
|
pub fn n_rows(&self) -> usize {
|
|
self.rows.len()
|
|
}
|
|
|
|
/// The logical row containing `line`, if `line` is a content line.
|
|
pub fn row_of_line(&self, line: usize) -> Option<usize> {
|
|
self.rows.iter().position(|r| r.contains(&line))
|
|
}
|
|
|
|
/// Content-line range of a logical row.
|
|
pub fn row_lines(&self, row: usize) -> Range<usize> {
|
|
self.rows[row].clone()
|
|
}
|
|
|
|
/// Display-column band of a column's cell interior: everything strictly
|
|
/// between the two flanking `│` glyphs (padding included).
|
|
pub fn band(&self, col: usize) -> Range<u16> {
|
|
self.junction_cols[col].saturating_add(1)..self.junction_cols[col + 1]
|
|
}
|
|
|
|
/// The cell at (`line`, `col`), or `None` when `line` is a border row or
|
|
/// `col` falls outside the grid. A click exactly on a `│` snaps to the
|
|
/// cell on its right (left for the closing border).
|
|
pub fn cell_at(&self, line: usize, col: u16) -> Option<CellRef> {
|
|
let row = self.row_of_line(line)?;
|
|
let first = *self.junction_cols.first().expect("non-empty");
|
|
let last = *self.junction_cols.last().expect("non-empty");
|
|
if col < first || col > last {
|
|
return None;
|
|
}
|
|
let c = match self.junction_cols.iter().rposition(|&j| j <= col) {
|
|
Some(j) if j == self.junction_cols.len() - 1 => self.n_cols() - 1,
|
|
Some(j) => j,
|
|
None => 0,
|
|
};
|
|
Some(CellRef { row, col: c })
|
|
}
|
|
|
|
/// The column whose content interior (band minus the renderer's one
|
|
/// padding column per side) contains `col`.
|
|
fn interior_col_at(&self, col: u16) -> Option<usize> {
|
|
(0..self.n_cols()).find(|&c| {
|
|
let band = self.band(c);
|
|
let lo = band.start.saturating_add(1);
|
|
let hi = band.end.saturating_sub(1);
|
|
(lo..hi).contains(&col)
|
|
})
|
|
}
|
|
|
|
/// Latched head-cell resolution: borders, padding, and divider rows
|
|
/// keep `held`; only another cell's content interior (or the grid's
|
|
/// outer edge, which clamps) moves it. Empty cells never capture it.
|
|
pub fn latched_cell_at(&self, held: CellRef, line: usize, col: u16) -> CellRef {
|
|
let row = if let Some(row) = self.row_of_line(line) {
|
|
row
|
|
} else if line < self.line_range.start {
|
|
0
|
|
} else if line >= self.line_range.end {
|
|
self.n_rows() - 1
|
|
} else {
|
|
held.row
|
|
};
|
|
let col = if let Some(col) = self.interior_col_at(col) {
|
|
col
|
|
} else if col < *self.junction_cols.first().expect("non-empty") {
|
|
0
|
|
} else if col > *self.junction_cols.last().expect("non-empty") {
|
|
self.n_cols() - 1
|
|
} else {
|
|
held.col
|
|
};
|
|
CellRef { row, col }
|
|
}
|
|
|
|
/// A cell's text: its per-line band slices trimmed and joined with a
|
|
/// space (cells wrap at spaces/punctuation, so a space join reconstructs
|
|
/// the content).
|
|
pub fn cell_text(&self, cell: CellRef, text_at: impl Fn(usize) -> Option<String>) -> String {
|
|
let band = self.band(cell.col);
|
|
let mut out = String::new();
|
|
for line in self.row_lines(cell.row) {
|
|
let Some(text) = text_at(line) else { continue };
|
|
let slice = crate::scrollback::types::slice_display_cols(&text, band.start, band.end);
|
|
let fragment = slice.trim();
|
|
if fragment.is_empty() {
|
|
continue;
|
|
}
|
|
if !out.is_empty() {
|
|
out.push(' ');
|
|
}
|
|
out.push_str(fragment);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// TSV for the rectangular cell range spanned by `a` and `b` (order
|
|
/// irrelevant): cells tab-joined, rows newline-joined. Tabs inside cell
|
|
/// text are flattened to spaces so the TSV shape survives.
|
|
pub fn grid_tsv(
|
|
&self,
|
|
a: CellRef,
|
|
b: CellRef,
|
|
text_at: impl Fn(usize) -> Option<String>,
|
|
) -> String {
|
|
let (r0, r1) = (a.row.min(b.row), a.row.max(b.row));
|
|
let (c0, c1) = (a.col.min(b.col), a.col.max(b.col));
|
|
let mut rows_out: Vec<String> = Vec::new();
|
|
for row in r0..=r1 {
|
|
let cells: Vec<String> = (c0..=c1)
|
|
.map(|col| {
|
|
self.cell_text(CellRef { row, col }, &text_at)
|
|
.replace('\t', " ")
|
|
})
|
|
.collect();
|
|
rows_out.push(cells.join("\t"));
|
|
}
|
|
rows_out.join("\n")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
/// Text source over a static list of lines.
|
|
fn src<'a>(lines: &'a [&'a str]) -> impl Fn(usize) -> Option<String> + 'a {
|
|
move |i| lines.get(i).map(|s| s.to_string())
|
|
}
|
|
|
|
const TABLE: &[&str] = &[
|
|
"Intro prose",
|
|
"┌─────────┬────────┐",
|
|
"│ Name │ Role │",
|
|
"├─────────┼────────┤",
|
|
"│ Alice │ Eng │",
|
|
"├─────────┼────────┤",
|
|
"│ Bob │ Design │",
|
|
"└─────────┴────────┘",
|
|
"Outro prose",
|
|
];
|
|
|
|
#[test]
|
|
fn detects_from_content_and_border_lines() {
|
|
for at in 1..=7 {
|
|
let geom = TableGeometry::detect(src(TABLE), at).expect("grid detected");
|
|
assert_eq!(geom.line_range(), 1..8);
|
|
assert_eq!(geom.n_cols(), 2);
|
|
assert_eq!(geom.n_rows(), 3);
|
|
assert_eq!(geom.row_lines(0), 2..3);
|
|
assert_eq!(geom.row_lines(2), 6..7);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn no_grid_outside_table() {
|
|
assert_eq!(TableGeometry::detect(src(TABLE), 0), None);
|
|
assert_eq!(TableGeometry::detect(src(TABLE), 8), None);
|
|
}
|
|
|
|
#[test]
|
|
fn cell_lookup_and_bands() {
|
|
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
|
// "│ Alice │ Eng │" — junctions at cols 0, 10, 19.
|
|
assert_eq!(geom.band(0), 1..10);
|
|
assert_eq!(geom.band(1), 11..19);
|
|
assert_eq!(geom.cell_at(4, 3), Some(CellRef { row: 1, col: 0 }));
|
|
assert_eq!(geom.cell_at(4, 12), Some(CellRef { row: 1, col: 1 }));
|
|
// Junction col snaps right; closing border snaps left.
|
|
assert_eq!(geom.cell_at(4, 10), Some(CellRef { row: 1, col: 1 }));
|
|
assert_eq!(geom.cell_at(4, 19), Some(CellRef { row: 1, col: 1 }));
|
|
assert_eq!(geom.cell_at(4, 0), Some(CellRef { row: 1, col: 0 }));
|
|
// Border rows have no cells.
|
|
assert_eq!(geom.cell_at(3, 3), None);
|
|
// Outside the grid columns.
|
|
assert_eq!(geom.cell_at(4, 25), None);
|
|
}
|
|
|
|
#[test]
|
|
fn latched_cell_moves_only_via_content_or_past_the_edge() {
|
|
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
|
let held = CellRef { row: 1, col: 0 };
|
|
// Another row's content line moves the row latch.
|
|
assert_eq!(geom.latched_cell_at(held, 6, 3), CellRef { row: 2, col: 0 });
|
|
// Divider and border rows keep the held row (no snap below).
|
|
assert_eq!(geom.latched_cell_at(held, 5, 3), held);
|
|
assert_eq!(geom.latched_cell_at(held, 1, 3), held);
|
|
// Above / below the grid clamps to the first / last row.
|
|
assert_eq!(geom.latched_cell_at(held, 0, 3), CellRef { row: 0, col: 0 });
|
|
assert_eq!(geom.latched_cell_at(held, 8, 3), CellRef { row: 2, col: 0 });
|
|
// "│ Alice │ Eng │" — junctions at 0, 10, 19; bands 1..10, 11..19.
|
|
// The junction and both flanking padding columns keep the held column.
|
|
assert_eq!(geom.latched_cell_at(held, 4, 9), held);
|
|
assert_eq!(geom.latched_cell_at(held, 4, 10), held);
|
|
assert_eq!(geom.latched_cell_at(held, 4, 11), held);
|
|
// The neighbor's content interior captures the latch.
|
|
assert_eq!(
|
|
geom.latched_cell_at(held, 4, 12),
|
|
CellRef { row: 1, col: 1 }
|
|
);
|
|
// Past the right edge clamps to the last column.
|
|
assert_eq!(
|
|
geom.latched_cell_at(held, 4, 40),
|
|
CellRef { row: 1, col: 1 }
|
|
);
|
|
// Latch releases symmetrically: held in Role, back into Name content.
|
|
let held_role = CellRef { row: 1, col: 1 };
|
|
assert_eq!(geom.latched_cell_at(held_role, 4, 10), held_role);
|
|
assert_eq!(
|
|
geom.latched_cell_at(held_role, 4, 5),
|
|
CellRef { row: 1, col: 0 }
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cell_text_and_tsv() {
|
|
let geom = TableGeometry::detect(src(TABLE), 4).unwrap();
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 1, col: 0 }, src(TABLE)),
|
|
"Alice"
|
|
);
|
|
assert_eq!(
|
|
geom.grid_tsv(
|
|
CellRef { row: 1, col: 0 },
|
|
CellRef { row: 2, col: 0 },
|
|
src(TABLE)
|
|
),
|
|
"Alice\nBob"
|
|
);
|
|
assert_eq!(
|
|
geom.grid_tsv(
|
|
CellRef { row: 2, col: 1 },
|
|
CellRef { row: 1, col: 0 },
|
|
src(TABLE)
|
|
),
|
|
"Alice\tEng\nBob\tDesign"
|
|
);
|
|
}
|
|
|
|
const WRAPPED: &[&str] = &[
|
|
"┌─────────┬──────────┐",
|
|
"│ Name │ Notes │",
|
|
"├─────────┼──────────┤",
|
|
"│ Alice │ likes │",
|
|
"│ │ long │",
|
|
"│ │ walks │",
|
|
"└─────────┴──────────┘",
|
|
];
|
|
|
|
#[test]
|
|
fn wrapped_cell_fragments_join_with_space() {
|
|
let geom = TableGeometry::detect(src(WRAPPED), 4).unwrap();
|
|
assert_eq!(geom.n_rows(), 2);
|
|
assert_eq!(geom.row_lines(1), 3..6);
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 1, col: 1 }, src(WRAPPED)),
|
|
"likes long walks"
|
|
);
|
|
// Empty fragments (the padding rows of the Name cell) are skipped.
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 1, col: 0 }, src(WRAPPED)),
|
|
"Alice"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn blockquoted_table_with_quote_bar_prefix() {
|
|
let quoted: &[&str] = &[
|
|
"│ ┌─────┬─────┐",
|
|
"│ │ A │ B │",
|
|
"│ ├─────┼─────┤",
|
|
"│ │ one │ two │",
|
|
"│ └─────┴─────┘",
|
|
];
|
|
let geom = TableGeometry::detect(src(quoted), 3).expect("quoted grid");
|
|
assert_eq!(geom.n_cols(), 2);
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 1, col: 0 }, src(quoted)),
|
|
"one"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wide_glyphs_use_display_columns() {
|
|
let emoji: &[&str] = &["┌──────┬──────┐", "│ 名前 │ ok │", "└──────┴──────┘"];
|
|
let geom = TableGeometry::detect(src(emoji), 1).expect("grid");
|
|
assert_eq!(geom.band(0), 1..7);
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 0, col: 0 }, src(emoji)),
|
|
"名前"
|
|
);
|
|
// Click on the second display column of 名 resolves to col 0.
|
|
assert_eq!(geom.cell_at(1, 3), Some(CellRef { row: 0, col: 0 }));
|
|
}
|
|
|
|
#[test]
|
|
fn inconsistent_junctions_bail() {
|
|
let broken: &[&str] = &[
|
|
"┌─────┬─────┐",
|
|
"│ A │ B │",
|
|
"├────────┼──┤", // misaligned divider
|
|
"│ one │ two │",
|
|
"└─────┴─────┘",
|
|
];
|
|
assert_eq!(TableGeometry::detect(src(broken), 1), None);
|
|
}
|
|
|
|
#[test]
|
|
fn unclosed_grid_bails() {
|
|
let unclosed: &[&str] = &["┌─────┬─────┐", "│ A │ B │", "prose again"];
|
|
assert_eq!(TableGeometry::detect(src(unclosed), 1), None);
|
|
}
|
|
|
|
#[test]
|
|
fn stray_bar_in_cell_content_is_not_a_junction() {
|
|
let stray: &[&str] = &["┌───────┬─────┐", "│ a │ b │ c │", "└───────┴─────┘"];
|
|
let geom = TableGeometry::detect(src(stray), 1).expect("grid");
|
|
assert_eq!(geom.n_cols(), 2);
|
|
// The stray │ inside the first cell is content, not a boundary.
|
|
assert_eq!(
|
|
geom.cell_text(CellRef { row: 0, col: 0 }, src(stray)),
|
|
"a │ b"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn plain_prose_and_rules_are_not_grids() {
|
|
let prose: &[&str] = &["hello world", "─────────", "goodbye"];
|
|
assert_eq!(TableGeometry::detect(src(prose), 0), None);
|
|
assert_eq!(TableGeometry::detect(src(prose), 1), None);
|
|
}
|
|
}
|