diff --git a/config/keyboard/Readme.md b/config/keyboard/Readme.md index 5f5ed971..72163f93 100644 --- a/config/keyboard/Readme.md +++ b/config/keyboard/Readme.md @@ -76,8 +76,13 @@ Both layouts use **Fenrir+F** for settings, **Fenrir+H** or **Fenrir+F1** for the tutorial, and **Fenrir+Control+S** to save settings. The tutorial describes loaded shortcuts and lets you browse unbound commands. -In table mode, word navigation moves between cells. **Fenrir+X** selects the -current row as headers. Outside table mode it places a text-selection mark. +In table mode, word navigation moves between cells. Numeric tables can use +visible headers automatically. Review starts at the first data cell when a +recognized table is at or immediately above the application cursor. +**Fenrir+X** selects the current row as headers, +overriding automatic selection. **Fenrir+Control+Shift+X** clears headers and +disables automatic selection until table mode is re-entered or the screen +session changes. Outside table mode, Fenrir+X places a text-selection mark. See the [user manual](../../docs/user.md) for clipboard, reading-region, spellcheck, and menu workflows. diff --git a/docs/fenrir.1 b/docs/fenrir.1 index 31df56fd..a90a4ca2 100644 --- a/docs/fenrir.1 +++ b/docs/fenrir.1 @@ -171,9 +171,40 @@ selections. In table mode, word navigation moves between cells, line start/end moves to the first/last cell, and first/last text moves to the first/last character in the current cell. -Place review on a header row and press \f[B]Fenrir+X\f[R] to use its -cells as column headers. -Outside table mode, Fenrir+X places a text\-selection mark. +Fenrir uses visible headings automatically when a text heading is +followed by a row with at least two numeric fields. +You can enter table mode, run \f[CR]free \-h\f[R], and review its cells +without setting headers yourself. +Aligned tables use shared column boundaries so an unnamed heading or an +empty field does not shift the remaining columns. +Multiword headings and values stay in their cells when the visible +alignment distinguishes them. +.PP +When you begin reviewing a recognized table at or immediately above the +application cursor, review starts at the first cell of its first data +row. +For example, after \f[CR]free \-h\f[R], current word reads +\f[CR]Mem:\f[R] and next word reads the total memory value. +The application cursor stays in place. +Existing review positions are preserved while output updates; manual +headers or clearing headers disable this automatic starting position. +.PP +Automatic headers are inferred from the visible output block being +reviewed; they are not remembered after the heading scrolls off screen. +Text\-only tables, wrapped output, and ambiguous or grouped headings may +require manual selection. +Place review on the desired header row and press \f[B]Fenrir+X\f[R] to +replace the automatic headers. +For grouped headings, select the row naming individual columns. +Manual headers remain until cleared, table mode is left, or the screen +session changes. +.PP +Press \f[B]Fenrir+Control+Shift+X\f[R] to clear headers and use generic +column numbers. +Automatic header assignment stays off until you leave and re\-enter +table mode or the screen session changes; \f[B]Fenrir+X\f[R] can still +set headers manually. +Outside table mode, Fenrir+X places a text\-selection mark instead. .SS Speech and automatic reading .IP \(bu 2 \f[B]Control\f[R] interrupts speech. diff --git a/docs/user.md b/docs/user.md index a92ee12d..c14ec4e5 100644 --- a/docs/user.md +++ b/docs/user.md @@ -106,9 +106,32 @@ The startup selection is `reading.mode`. In table mode, word navigation moves between cells, line start/end moves to the first/last cell, and first/last text moves to the first/last character in the -current cell. Place review on a header row and press **Fenrir+X** to use its -cells as column headers. Outside table mode, Fenrir+X places a text-selection -mark. +current cell. Fenrir uses visible headings automatically when a text heading is +followed by a row with at least two numeric fields. You can enter table mode, +run `free -h`, and review its cells without setting headers yourself. Aligned +tables use shared column boundaries so an unnamed heading or an empty field +does not shift the remaining columns. Multiword headings and values stay in +their cells when the visible alignment distinguishes them. + +When you begin reviewing a recognized table at or immediately above the +application cursor, review starts at the first cell of its first data row. For +example, after `free -h`, current word reads `Mem:` and next word reads the total +memory value. The application cursor stays in place. Existing review positions +are preserved while output updates; manual headers or clearing headers disable +this automatic starting position. + +Automatic headers are inferred from the visible output block being reviewed; +they are not remembered after the heading scrolls off screen. Text-only tables, +wrapped output, and ambiguous or grouped headings may require manual selection. +Place review on the desired header row and press **Fenrir+X** to replace the +automatic headers. For grouped headings, select the row naming individual +columns. Manual headers remain until cleared, table mode is left, or the screen +session changes. + +Press **Fenrir+Control+Shift+X** to clear headers and use generic column numbers. +Automatic header assignment stays off until you leave and re-enter table mode +or the screen session changes; **Fenrir+X** can still set headers manually. +Outside table mode, Fenrir+X places a text-selection mark instead. ## Speech and automatic reading diff --git a/docs/user.txt b/docs/user.txt index 23856aca..7c45a4a6 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -120,9 +120,34 @@ on desktop or Fenrir+Y on laptop. The startup selection is reading.mode. In table mode, word navigation moves between cells, line start/end moves to the first/last cell, and first/last text moves to the first/last -character in the current cell. Place review on a header row and press -Fenrir+X to use its cells as column headers. Outside table mode, -Fenrir+X places a text-selection mark. +character in the current cell. Fenrir uses visible headings +automatically when a text heading is followed by a row with at least two +numeric fields. You can enter table mode, run free -h, and review its +cells without setting headers yourself. Aligned tables use shared column +boundaries so an unnamed heading or an empty field does not shift the +remaining columns. Multiword headings and values stay in their cells +when the visible alignment distinguishes them. + +When you begin reviewing a recognized table at or immediately above the +application cursor, review starts at the first cell of its first data +row. For example, after free -h, current word reads Mem: and next word +reads the total memory value. The application cursor stays in place. +Existing review positions are preserved while output updates; manual +headers or clearing headers disable this automatic starting position. + +Automatic headers are inferred from the visible output block being +reviewed; they are not remembered after the heading scrolls off screen. +Text-only tables, wrapped output, and ambiguous or grouped headings may +require manual selection. Place review on the desired header row and +press Fenrir+X to replace the automatic headers. For grouped headings, +select the row naming individual columns. Manual headers remain until +cleared, table mode is left, or the screen session changes. + +Press Fenrir+Control+Shift+X to clear headers and use generic column +numbers. Automatic header assignment stays off until you leave and +re-enter table mode or the screen session changes; Fenrir+X can still +set headers manually. Outside table mode, Fenrir+X places a +text-selection mark instead. Speech and automatic reading diff --git a/internal/reader/tracking_test.go b/internal/reader/tracking_test.go index e4a72277..c7935eb1 100644 --- a/internal/reader/tracking_test.go +++ b/internal/reader/tracking_test.go @@ -81,6 +81,33 @@ func TestTrackingCycleDefaultsAndReview(t *testing.T) { } } +func TestTableHeadersAfterCommandOutputAndExplicitClear(t *testing.T) { + r := New() + r.Update(fixture("$ free -h", "", "", "", "")) + r.Configure_tracking("table") + next := fixture("$ free -h", " total used", "Mem: 12 3", "Swap: 4 0", "$ ") + next.Y = 4 + if output := r.Update(next); len(output) == 0 || r.review.Active() { + t.Fatal("table placement suppressed automatic output or entered review early", output) + } + if got := r.Command(command.CurrentWord).Text; got != "Mem: Column 1" { + t.Fatal("review did not start at first data cell", got) + } + if got := r.Command(command.NextWord).Text; got != "12 total" { + t.Fatal("command output headers were not available through reader dispatch", got) + } + r.Clear_marks() + if got := r.Command(command.CurrentWord).Text; got != "12 Column 2" { + t.Fatal("reader clear did not clear headers", got) + } + r.Command(command.PreviousLine) + r.Set_mark() + r.Command(command.NextLine) + if got := r.Command(command.CurrentWord).Text; got != "12 total" { + t.Fatal("manual selection after clear failed through reader dispatch", got) + } +} + func TestHighlightSelectionAndSuppression(t *testing.T) { selected := screen.Attribute{Foreground: 7, Background: 4} base := fixture("First message", "Second message", "Third message", "status") diff --git a/internal/review/marks.go b/internal/review/marks.go index ba93fce3..5e6bf7e3 100644 --- a/internal/review/marks.go +++ b/internal/review/marks.go @@ -43,7 +43,15 @@ func (r *Review) Set_mark() presentation.Message { return presentation.Message{Text: i18n.T("set mark"), Cue: cue} } -func (r *Review) Clear_marks() { r.markCount = 0 } +func (r *Review) Clear_marks() { + r.markCount = 0 + if r.tableMode { + r.tableHeaders = nil + r.tableLayout = tableLayout{} + r.tableManual = false + r.tableAutoDisabled = true + } +} // Marked_region is rectangular, unlike clipboard selection. A single mark // optionally selects the rest of its physical row. Table header marks never diff --git a/internal/review/review.go b/internal/review/review.go index 5a716295..7160af0e 100644 --- a/internal/review/review.go +++ b/internal/review/review.go @@ -41,6 +41,9 @@ type Review struct { tableMode bool tableColumn int tableHeaders []string + tableLayout tableLayout + tableManual bool + tableAutoDisabled bool } func (r *Review) Configure(lineBreak, endOfScreen, leaveOnScreenChange bool) { @@ -99,6 +102,8 @@ func (r *Review) Update(next screen.Snapshot) bool { if switched { r.Clear_marks() r.tableHeaders = nil + r.tableLayout = tableLayout{} + r.tableManual, r.tableAutoDisabled = false, false r.tableColumn = 0 } if !next.Active { @@ -143,6 +148,9 @@ func (r *Review) execute(id command.ID) string { case command.AttributeCursor, command.CursorLine, command.CursorColumn, command.LineToCursor, command.LineFromCursor, command.FollowCursor: // Reports use the active cursor without entering detached review. default: + if !r.reviewing { + r.start_table_review() + } r.reviewing = true } if r.tableMode { diff --git a/internal/review/spelling.go b/internal/review/spelling.go index 72820653..c61ecab7 100644 --- a/internal/review/spelling.go +++ b/internal/review/spelling.go @@ -14,7 +14,7 @@ import ( func (r *Review) spelling_text(scope command.ID, phonetic bool) string { var text string if r.tableMode && scope != command.CurrentLine { - cells := table_cells(r.screen.Line(r.y)) + cells := r.current_table_cells() if len(cells) == 0 || r.tableColumn >= len(cells) { return i18n.T("no table data") } diff --git a/internal/review/table.go b/internal/review/table.go index 9843b8ea..16f27b00 100644 --- a/internal/review/table.go +++ b/internal/review/table.go @@ -19,7 +19,6 @@ package review import ( "strconv" - "strings" "unicode" "git.stormux.org/storm/fenrir/internal/command" @@ -42,13 +41,7 @@ func table_cells(line []rune) []TableCell { if end == 0 { return nil } - var separator rune - for _, candidate := range []rune{',', '|', ';', '\t'} { - if strings.ContainsRune(string(line[:end]), candidate) { - separator = candidate - break - } - } + separator := table_separator(line[:end]) var cells []TableCell appendCell := func(start, end int) { for start < end && unicode.IsSpace(line[start]) { @@ -93,13 +86,15 @@ func (r *Review) Set_table_mode(enabled bool) { } r.tableMode = enabled r.tableHeaders = nil + r.tableLayout = tableLayout{} + r.tableManual, r.tableAutoDisabled = false, false if enabled && r.ready { r.sync_table_column() } } func (r *Review) sync_table_column() { - cells := table_cells(r.screen.Line(r.y)) + cells := r.current_table_cells() r.tableColumn = 0 for index, cell := range cells { if r.x < cell.start { @@ -127,11 +122,13 @@ func (r *Review) table_cell_text(cells []TableCell) string { } func (r *Review) set_table_headers() presentation.Message { - cells := table_cells(r.screen.Line(r.y)) + layout := r.layout_at(r.y) + cells := layout.cells(r.screen.Line(r.y)) if len(cells) == 0 { return presentation.Message{Text: i18n.T("could not set header row")} } r.tableHeaders = nil + r.tableLayout, r.tableManual = layout, true for _, cell := range cells { r.tableHeaders = append(r.tableHeaders, string(r.screen.Line(r.y)[cell.start:cell.end])) } @@ -145,7 +142,7 @@ func (r *Review) table_command(id command.ID) (string, bool) { default: return "", false } - cells := table_cells(r.screen.Line(r.y)) + cells := r.current_table_cells() if len(cells) == 0 { return i18n.T("no table data"), true } diff --git a/internal/review/table_layout.go b/internal/review/table_layout.go new file mode 100644 index 00000000..2d039dde --- /dev/null +++ b/internal/review/table_layout.go @@ -0,0 +1,250 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "strings" + "unicode" +) + +// A layout belongs to the whole table, rather than being guessed separately +// for each row. Fixed screen boundaries preserve empty and multiword cells. +type tableLayout struct { + separator rune + breaks []int +} + +func table_separator(line []rune) rune { + for _, candidate := range []rune{',', '|', ';', '\t'} { + if strings.ContainsRune(string(line), candidate) { + return candidate + } + } + return 0 +} + +func table_trim_cell(line []rune, start, end int) TableCell { + for start < end && (unicode.IsSpace(line[start]) || line[start] == 0) { + start++ + } + for end > start && (unicode.IsSpace(line[end-1]) || line[end-1] == 0) { + end-- + } + return TableCell{start: start, end: end} +} + +func (layout tableLayout) cells(line []rune) []TableCell { + if strings.Trim(string(line), " \x00\t\r") == "" { + return nil + } + if layout.separator != 0 { + var cells []TableCell + start := 0 + for index, value := range line { + if value == layout.separator { + cells = append(cells, table_trim_cell(line, start, index)) + start = index + 1 + } + } + return append(cells, table_trim_cell(line, start, len(line))) + } + if len(layout.breaks) == 0 { + return table_cells(line) + } + var cells []TableCell + start := 0 + for _, boundary := range layout.breaks { + boundary = min(boundary, len(line)) + cells = append(cells, table_trim_cell(line, start, boundary)) + start = boundary + } + return append(cells, table_trim_cell(line, start, len(line))) +} + +// Shared whitespace gutters separate aligned columns, including single-space +// gutters. A gutter needs text after it in the heading and at least one data +// row, so spaces inside "Mounted on" do not split an empty data tail. +func aligned_table_layout(header []rune, rows ...[]rune) tableLayout { + var layout tableLayout + width := len(header) + for _, data := range rows { + width = min(width, len(data)) + } + blank := func(index int) bool { + if header[index] != ' ' && header[index] != 0 { + return false + } + for _, data := range rows { + if data[index] != ' ' && data[index] != 0 { + return false + } + } + return true + } + for index := 0; index < width; index++ { + if !blank(index) { + continue + } + start := index + for index < width && blank(index) { + index++ + } + before, after := false, false + for _, data := range rows { + before = before || table_trim_cell(data, 0, start).start != start + after = after || table_trim_cell(data, index, width).start != width + } + if !before || !after || table_trim_cell(header, index, width).start == width { + continue + } + layout.breaks = append(layout.breaks, (start+index)/2) + } + return layout +} + +func (r *Review) layout_at(row int) tableLayout { + header := r.screen.Line(row) + separator := table_separator(header) + if separator != 0 { + return tableLayout{separator: separator} + } + if row+1 < r.screen.Rows { + rows := [][]rune{r.screen.Line(row + 1)} + // Include subsequent numeric rows when inferring gutters: longer names + // and values must not cross a boundary inferred from a shorter row. + if table_numeric_fields(rows[0]) >= 2 { + for next := row + 2; next < r.screen.Rows; next++ { + line := r.screen.Line(next) + if table_numeric_fields(line) == 0 { + break + } + rows = append(rows, line) + } + } + return aligned_table_layout(header, rows...) + } + return tableLayout{} +} + +func table_numeric(text string) bool { + text = strings.TrimSpace(text) + text = strings.TrimLeft(text, "+-~<>") + return len(text) > 0 && text[0] >= '0' && text[0] <= '9' +} + +func table_numeric_fields(line []rune) int { + numbers := 0 + for _, field := range strings.Fields(string(line)) { + if table_numeric(field) { + numbers++ + } + } + return numbers +} + +func table_evidence(line []rune, cells []TableCell) (labels, numbers int) { + for _, cell := range cells { + text := string(line[cell.start:cell.end]) + if text != "" { + labels++ + if table_numeric(text) { + numbers++ + } + } + } + return +} + +// Discover headers while reviewing a contiguous output block. Requiring a +// textual heading followed by several numeric fields avoids treating the shell +// prompt, command echo, ordinary prose, or headerless numeric data as headers. +// Text-only tables remain available through explicit header selection. +func (r *Review) current_table_cells() []TableCell { + line := r.screen.Line(r.y) + if r.tableManual { + return r.tableLayout.cells(line) + } + r.tableHeaders = nil + layout, headerRow, ok := r.automatic_table_at(r.y) + if !ok { + return table_cells(line) + } + if !r.tableAutoDisabled { + header := r.screen.Line(headerRow) + for _, cell := range layout.cells(header) { + r.tableHeaders = append(r.tableHeaders, string(header[cell.start:cell.end])) + } + } + return layout.cells(line) +} + +// Locate the visible table containing a row without changing review state. +func (r *Review) automatic_table_at(targetRow int) (tableLayout, int, bool) { + line := r.screen.Line(targetRow) + for row := targetRow; row >= 0; row-- { + header := r.screen.Line(row) + if strings.Trim(string(header), " \x00\t\r") == "" { + break + } + if row+1 >= r.screen.Rows || table_numeric_fields(header) > 0 { + continue + } + layout := r.layout_at(row) + cells := layout.cells(header) + labels, numbers := table_evidence(header, cells) + if labels < 2 || numbers != 0 { + continue + } + data := r.screen.Line(row + 1) + dataCells := layout.cells(data) + _, numbers = table_evidence(data, dataCells) + if len(dataCells) != len(cells) || numbers < 2 { + continue + } + current := layout.cells(line) + if row != targetRow { + _, numbers = table_evidence(line, current) + if numbers == 0 || len(current) != len(cells) { + continue + } + contiguous := true + for between := row + 1; between < targetRow; between++ { + middle := r.screen.Line(between) + middleCells := layout.cells(middle) + _, numbers = table_evidence(middle, middleCells) + if numbers == 0 || len(middleCells) != len(cells) { + contiguous = false + break + } + } + if !contiguous { + continue + } + } + return layout, row, true + } + return tableLayout{}, 0, false +} + +// Seed a new review at the first data cell of the table at, or immediately +// above, the application cursor. Do this on entry rather than during Update: +// detached review would otherwise suppress automatic reading of new output. +func (r *Review) start_table_review() { + if !r.tableMode || r.tableManual || r.tableAutoDisabled { + return + } + for _, row := range []int{r.screen.Y, r.screen.Y - 1} { + if row < 0 || row >= r.screen.Rows { + continue + } + layout, headerRow, ok := r.automatic_table_at(row) + if !ok || row == headerRow { + continue + } + r.y = headerRow + 1 + r.x = min(layout.cells(r.screen.Line(r.y))[0].start, r.screen.Cols-1) + r.tableColumn = 0 + return + } +} diff --git a/internal/review/table_layout_test.go b/internal/review/table_layout_test.go new file mode 100644 index 00000000..84bdf607 --- /dev/null +++ b/internal/review/table_layout_test.go @@ -0,0 +1,348 @@ +// Copyright (c) 2026 Storm Dragon +// SPDX-License-Identifier: GPL-3.0-or-later + +package review + +import ( + "strings" + "testing" + + "git.stormux.org/storm/fenrir/internal/command" + "git.stormux.org/storm/fenrir/internal/screen" +) + +func table_fixture(lines ...string) screen.Snapshot { + width := 1 + for _, line := range lines { + width = max(width, len([]rune(line))) + } + s := screen.Snapshot{Terminal: 2, Active: true, Rows: len(lines), Cols: width} + for _, text := range lines { + line := []rune(text) + s.Cells = append(s.Cells, line...) + s.Cells = append(s.Cells, []rune(strings.Repeat(" ", width-len(line)))...) + } + return s +} + +var freeTable = []string{ + " total used free shared buff/cache available", + "Mem: 31Gi 9.5Gi 9.7Gi 3.0Gi 15Gi 21Gi", + "Swap: 15Gi 46Mi 15Gi", +} + +func TestTableFreeAutomaticAndManualHeaders(t *testing.T) { + for _, manual := range []bool{false, true} { + r := Review{} + r.Update(table_fixture(freeTable...)) + r.Set_table_mode(true) + r.Execute(command.CurrentLine) + if manual { + r.Set_mark() + } + r.Execute(command.NextLine) + for col, want := range []string{"Mem: Column 1", "31Gi total", "9.5Gi used", "9.7Gi free", "3.0Gi shared", "15Gi buff/cache", "21Gi available"} { + id := command.CurrentWord + if col != 0 { + id = command.NextWord + } + if got := r.Execute(id).Text; got != want { + t.Fatalf("manual=%v col=%d got %q want %q", manual, col, got, want) + } + } + r.Execute(command.NextLine) + if got := r.Execute(command.CurrentWord).Text; got != "blank available" { + t.Fatal("missing trailing fields shifted columns", got) + } + r.Execute(command.LineStart) + if got := r.Execute(command.NextWord).Text; got != "15Gi total" { + t.Fatal(got) + } + if got := r.Execute(command.SpellWord); got.Spelling == nil || got.Spelling.Text != "15Gi" { + t.Fatal("spelling used a different layout", got) + } + } +} + +func TestTableLinuxCommandLayouts(t *testing.T) { + for _, trial := range []struct { + name string + lines []string + wants []string + }{ + {"df", []string{ + "Filesystem Size Used Avail Use% Mounted on", + "/dev/example1 491G 244G 222G 53% /", + }, []string{"/dev/example1 Filesystem", "491G Size", "244G Used", "222G Avail", "53% Use%", "/ Mounted on"}}, + {"lsblk", []string{ + "NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINTS", + "sda 8:0 0 64G 0 disk", + "└─example01 8:1 0 64G 0 part /media/example", + }, []string{"sda NAME", "8:0 MAJ:MIN", "0 RM", "64G SIZE", "0 RO", "disk TYPE", "blank MOUNTPOINTS"}}, + {"ps", []string{ + "USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND", + "Username 123 0.0 0.1 12345 1234 pts/1 S 10:30 0:00 example --flag a,b", + }, []string{"Username USER", "123 PID", "0.0 %CPU", "0.1 %MEM", "12345 VSZ", "1234 RSS", "pts/1 TTY", "S STAT", "10:30 START", "0:00 TIME", "example --flag a,b COMMAND"}}, + {"delimited", []string{"Device|Used|Free", "example|12|34"}, []string{"example Device", "12 Used", "34 Free"}}, + {"ss", []string{ + "State Recv-Q Send-Q Local Address:Port Peer Address:Port", + "LISTEN 0 128 127.0.0.1:1234 0.0.0.0:*", + }, []string{"LISTEN State", "0 Recv-Q", "128 Send-Q", "127.0.0.1:1234 Local Address:Port", "0.0.0.0:* Peer Address:Port"}}, + {"vmstat", []string{ + "procs --------memory-------", + " r b swpd free buff", + " 1 0 12345 678901 23456", + }, []string{"1 r", "0 b", "12345 swpd", "678901 free", "23456 buff"}}, + } { + t.Run(trial.name, func(t *testing.T) { + var r Review + s := table_fixture(trial.lines...) + s.Y = 1 + if trial.name == "vmstat" { + s.Y = 2 + } + r.Update(s) + r.Set_table_mode(true) + for col, want := range trial.wants { + id := command.CurrentWord + if col != 0 { + id = command.NextWord + } + if got := r.Execute(id).Text; got != want { + t.Fatalf("col %d: got %q want %q", col, got, want) + } + } + if trial.name == "lsblk" { + r.Execute(command.NextLine) + if got := r.Execute(command.LineStart).Text; got != "└─example01 NAME" { + t.Fatal("longer row split by previous row's gaps", got) + } + } + }) + } +} + +func TestTableClearHeadersOverrideAndModeReset(t *testing.T) { + var r Review + r.Update(table_fixture(freeTable...)) + r.Set_table_mode(true) + r.Execute(command.NextLine) + if got := r.Execute(command.NextWord).Text; got != "31Gi total" { + t.Fatal(got) + } + r.Clear_marks() + if got := r.Execute(command.CurrentWord).Text; got != "31Gi Column 2" { + t.Fatal("clear reassigned headers", got) + } + r.Execute(command.LineEnd) + r.Execute(command.NextLine) + if got := r.Execute(command.CurrentWord).Text; got != "blank Column 7" { + t.Fatal("clearing headers discarded column geometry", got) + } + r.Execute(command.PreviousLine) + r.Execute(command.LineStart) + r.Execute(command.NextWord) + r.Execute(command.PreviousLine) + if got := r.Set_mark().Text; got != "header row set" { + t.Fatal(got) + } + r.Execute(command.NextLine) + if got := r.Execute(command.CurrentWord).Text; got != "31Gi total" { + t.Fatal("manual override after clear", got) + } + r.Clear_marks() + r.Set_table_mode(false) + r.Set_table_mode(true) + if got := r.Execute(command.CurrentWord).Text; got != "31Gi total" { + t.Fatal("mode reset did not restore automatic headers", got) + } +} + +func TestTableAutomaticHeadersStayInOutputBlock(t *testing.T) { + for _, lines := range [][]string{ + {"$ example", "12 34", "56 78"}, + {"Some ordinary prose", "More ordinary prose"}, + {"Name Used Free", "one 12 34", "", "56 78"}, + {"Name Used Free", "one 12 34", "$ next", "56 78"}, + } { + var r Review + s := table_fixture(lines...) + s.Y = len(lines) - 1 + r.Update(s) + r.Set_table_mode(true) + r.Execute(command.CurrentWord) + if len(r.tableHeaders) != 0 { + t.Fatal("guessed an unrelated header", lines, r.tableHeaders) + } + } +} + +func TestTableAutomaticHeadersFollowNewOutput(t *testing.T) { + var r Review + after := table_fixture(append(append([]string{"$ free -h"}, freeTable...), "$ ")...) + before := after + before.Cells = []rune(strings.Repeat(" ", len(after.Cells))) + copy(before.Cells, []rune("$ free -h")) + before.Y = 1 + r.Update(before) + r.Set_table_mode(true) + after.Y = 4 + r.Update(after) + if got := r.Execute(command.CurrentWord).Text; got != "Mem: Column 1" { + t.Fatal("new table did not place review at the first cell", got) + } + if got := r.Execute(command.NextWord).Text; got != "31Gi total" { + t.Fatal("new output not recognized", got) + } + // Replace the output without changing screen identity or dimensions. + for index := range after.Cells { + after.Cells[index] = ' ' + } + r.Update(after) + if got := r.Execute(command.CurrentWord).Text; got != "no table data" || len(r.tableHeaders) != 0 { + t.Fatal("stale automatic headers", got, r.tableHeaders) + } +} + +func TestTableReviewLandingOnlyOnEntry(t *testing.T) { + var r Review + s := table_fixture(append(append([]string{"$ free -h"}, freeTable...), "$ ")...) + s.Y, s.X = 4, 2 + r.Update(s) + r.Set_table_mode(true) + if got := r.Execute(command.CurrentWord).Text; got != "Mem: Column 1" || r.x != 0 || r.y != 2 || r.tableColumn != 0 { + t.Fatal("wrong initial cell", got, r.x, r.y, r.tableColumn) + } + if r.screen.X != 2 || r.screen.Y != 4 { + t.Fatal("moved application cursor") + } + r.Execute(command.NextLine) + r.Execute(command.NextWord) + r.Update(s) + if got := r.Execute(command.CurrentWord).Text; got != "15Gi total" || r.y != 3 { + t.Fatal("existing review was pulled back to the first row", got, r.y) + } +} + +func TestTableReviewLandingGuards(t *testing.T) { + for _, trial := range []string{"cursor", "highlight", "cleared", "unrelated output", "manual"} { + t.Run(trial, func(t *testing.T) { + var r Review + lines := append(append([]string{}, freeTable...), "$ ") + if trial == "unrelated output" { + lines = append(lines[:3], "unrelated output", "$ ") + } + s := table_fixture(lines...) + s.Y = len(lines) - 1 + r.Update(s) + r.Set_table_mode(trial != "cursor" && trial != "highlight") + if trial == "cleared" { + r.Clear_marks() + } + if trial == "manual" { + r.Execute(command.Top) + r.Set_mark() + r.Resume_tracking() + } + r.Execute(command.CurrentWord) + if r.y != s.Y { + t.Fatal("unexpected review jump", trial, r.y, s.Y) + } + }) + } +} + +func TestTableReviewLandingFindsLatestOutput(t *testing.T) { + var r Review + s := table_fixture("Name|Used|Free", "old|12|34", "$ next", "Name|Used|Free", "new|56|78", "$ ") + s.Y = 5 + r.Update(s) + r.Set_table_mode(true) + if got := r.Execute(command.CurrentWord).Text; got != "new Name" || r.y != 4 { + t.Fatal("landed on an older table", got, r.y) + } +} + +func TestTableManualHeadersOverrideAutomaticAndClear(t *testing.T) { + var r Review + r.Update(table_fixture("Name|Used|Free", "example|12|34", "Custom|First|Second", "other||56")) + r.Set_table_mode(true) + r.Execute(command.NextLine) + if got := r.Execute(command.NextWord).Text; got != "12 Used" { + t.Fatal(got) + } + r.Execute(command.NextLine) + r.Set_mark() + r.Execute(command.NextLine) + if got := r.Execute(command.CurrentWord).Text; got != "blank First" { + t.Fatal("manual headers did not replace automatic", got) + } + r.Clear_marks() + if got := r.Execute(command.CurrentWord).Text; got != "blank Column 2" { + t.Fatal(got) + } + r.Set_table_mode(false) + if got := r.Set_mark().Text; got != "set mark" { + t.Fatal("ordinary marks broken", got) + } +} + +func TestTableEmptyInteriorFieldAndTextOnlyManualHeader(t *testing.T) { + var r Review + r.Update(table_fixture("Name Used Free", "alpha 12 34", "beta 56")) + r.Set_table_mode(true) + r.Execute(command.NextLine) + r.Execute(command.NextWord) + r.Execute(command.NextLine) + if got := r.Execute(command.CurrentWord).Text; got != "blank Used" { + t.Fatal("empty interior column shifted", got) + } + if got := r.Execute(command.NextWord).Text; got != "56 Free" { + t.Fatal(got) + } + r = Review{} + r.Update(table_fixture("Name Description", "alpha two words")) + r.Set_table_mode(true) + r.Execute(command.CurrentWord) + if len(r.tableHeaders) != 0 { + t.Fatal("text-only headers should require selection") + } + r.Set_mark() + r.Execute(command.NextLine) + if got := r.Execute(command.NextWord).Text; got != "two words Description" { + t.Fatal("manual text-only table", got) + } +} + +func TestTableAutomaticHeaderSessionResetAndReplacement(t *testing.T) { + for _, change := range []string{"terminal", "generation", "resize"} { + t.Run(change, func(t *testing.T) { + var r Review + s := table_fixture("Name|Used|Free", "example|12|34") + s.Y = 1 + r.Update(s) + r.Set_table_mode(true) + r.Execute(command.NextWord) + r.Clear_marks() + switch change { + case "terminal": + s.Terminal++ + case "generation": + s.Generation++ + case "resize": + s = table_fixture("Name|Used|Free", "example|12|34", "") + s.Y = 1 + } + r.Update(s) + if got := r.Execute(command.NextWord).Text; got != "12 Used" { + t.Fatal("new session retained header suppression", got) + } + // Same dimensions and session: changed headings must be rediscovered. + copy(s.Cells, []rune("Name|Busy|Idle")) + r.Update(s) + if got := r.Execute(command.CurrentWord).Text; got != "12 Busy" { + t.Fatal("reused stale automatic header", got) + } + }) + } +} diff --git a/internal/review/vertical_character.go b/internal/review/vertical_character.go index 2372ec8c..23f1151c 100644 --- a/internal/review/vertical_character.go +++ b/internal/review/vertical_character.go @@ -21,7 +21,7 @@ func (r *Review) vertical_character(direction int) string { // clamp the column. Table review also retains the selected cell: an // absolute column outside it must not read a neighbouring cell instead. if r.tableMode { - cells := table_cells(r.screen.Line(r.y)) + cells := r.current_table_cells() if r.tableColumn >= len(cells) { return i18n.T("no table data") }