Sneaked in a few last minute bug fixes for table mode.

This commit is contained in:
Storm Dragon committed 2026-10-30 18:56:58 -04:00
1 parent 2c893dccb9
commit a27f274099
12 files changed
+747 -25

No files matched your search

+7 -2
View File
@@ -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.
+34 -3
View File
@@ -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.
+26 -3
View File
@@ -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
+28 -3
View File
@@ -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
+27
View File
@@ -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")
+9 -1
View File
@@ -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
+8
View File
@@ -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 {
+1 -1
View File
@@ -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")
}
+8 -11
View File
@@ -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
}
+250
View File
@@ -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
}
}
+348
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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")
}