Merge branch 'testing'
This commit is contained in:
commit
03f177bdaf
12 files changed
+747
-25
No files matched your search
@@ -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
|
the tutorial, and **Fenrir+Control+S** to save settings. The tutorial describes
|
||||||
loaded shortcuts and lets you browse unbound commands.
|
loaded shortcuts and lets you browse unbound commands.
|
||||||
|
|
||||||
In table mode, word navigation moves between cells. **Fenrir+X** selects the
|
In table mode, word navigation moves between cells. Numeric tables can use
|
||||||
current row as headers. Outside table mode it places a text-selection mark.
|
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,
|
See the [user manual](../../docs/user.md) for clipboard, reading-region,
|
||||||
spellcheck, and menu workflows.
|
spellcheck, and menu workflows.
|
||||||
+34
-3
@@ -171,9 +171,40 @@ selections.
|
|||||||
In table mode, word navigation moves between cells, line start/end moves
|
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
|
to the first/last cell, and first/last text moves to the first/last
|
||||||
character in the current cell.
|
character in the current cell.
|
||||||
Place review on a header row and press \f[B]Fenrir+X\f[R] to use its
|
Fenrir uses visible headings automatically when a text heading is
|
||||||
cells as column headers.
|
followed by a row with at least two numeric fields.
|
||||||
Outside table mode, Fenrir+X places a text\-selection mark.
|
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
|
.SS Speech and automatic reading
|
||||||
.IP \(bu 2
|
.IP \(bu 2
|
||||||
\f[B]Control\f[R] interrupts speech.
|
\f[B]Control\f[R] interrupts speech.
|
||||||
|
|||||||
+26
-3
@@ -106,9 +106,32 @@ The startup selection is `reading.mode`.
|
|||||||
|
|
||||||
In table mode, word navigation moves between cells, line start/end moves to the
|
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
|
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
|
current cell. Fenrir uses visible headings automatically when a text heading is
|
||||||
cells as column headers. Outside table mode, Fenrir+X places a text-selection
|
followed by a row with at least two numeric fields. You can enter table mode,
|
||||||
mark.
|
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
|
## Speech and automatic reading
|
||||||
|
|
||||||
|
|||||||
+28
-3
@@ -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
|
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
|
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
|
character in the current cell. Fenrir uses visible headings
|
||||||
Fenrir+X to use its cells as column headers. Outside table mode,
|
automatically when a text heading is followed by a row with at least two
|
||||||
Fenrir+X places a text-selection mark.
|
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
|
Speech and automatic reading
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestHighlightSelectionAndSuppression(t *testing.T) {
|
||||||
selected := screen.Attribute{Foreground: 7, Background: 4}
|
selected := screen.Attribute{Foreground: 7, Background: 4}
|
||||||
base := fixture("First message", "Second message", "Third message", "status")
|
base := fixture("First message", "Second message", "Third message", "status")
|
||||||
|
|||||||
@@ -43,7 +43,15 @@ func (r *Review) Set_mark() presentation.Message {
|
|||||||
return presentation.Message{Text: i18n.T("set mark"), Cue: cue}
|
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
|
// Marked_region is rectangular, unlike clipboard selection. A single mark
|
||||||
// optionally selects the rest of its physical row. Table header marks never
|
// optionally selects the rest of its physical row. Table header marks never
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ type Review struct {
|
|||||||
tableMode bool
|
tableMode bool
|
||||||
tableColumn int
|
tableColumn int
|
||||||
tableHeaders []string
|
tableHeaders []string
|
||||||
|
tableLayout tableLayout
|
||||||
|
tableManual bool
|
||||||
|
tableAutoDisabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Review) Configure(lineBreak, endOfScreen, leaveOnScreenChange bool) {
|
func (r *Review) Configure(lineBreak, endOfScreen, leaveOnScreenChange bool) {
|
||||||
@@ -99,6 +102,8 @@ func (r *Review) Update(next screen.Snapshot) bool {
|
|||||||
if switched {
|
if switched {
|
||||||
r.Clear_marks()
|
r.Clear_marks()
|
||||||
r.tableHeaders = nil
|
r.tableHeaders = nil
|
||||||
|
r.tableLayout = tableLayout{}
|
||||||
|
r.tableManual, r.tableAutoDisabled = false, false
|
||||||
r.tableColumn = 0
|
r.tableColumn = 0
|
||||||
}
|
}
|
||||||
if !next.Active {
|
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:
|
case command.AttributeCursor, command.CursorLine, command.CursorColumn, command.LineToCursor, command.LineFromCursor, command.FollowCursor:
|
||||||
// Reports use the active cursor without entering detached review.
|
// Reports use the active cursor without entering detached review.
|
||||||
default:
|
default:
|
||||||
|
if !r.reviewing {
|
||||||
|
r.start_table_review()
|
||||||
|
}
|
||||||
r.reviewing = true
|
r.reviewing = true
|
||||||
}
|
}
|
||||||
if r.tableMode {
|
if r.tableMode {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
func (r *Review) spelling_text(scope command.ID, phonetic bool) string {
|
func (r *Review) spelling_text(scope command.ID, phonetic bool) string {
|
||||||
var text string
|
var text string
|
||||||
if r.tableMode && scope != command.CurrentLine {
|
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) {
|
if len(cells) == 0 || r.tableColumn >= len(cells) {
|
||||||
return i18n.T("no table data")
|
return i18n.T("no table data")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ package review
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"git.stormux.org/storm/fenrir/internal/command"
|
"git.stormux.org/storm/fenrir/internal/command"
|
||||||
@@ -42,13 +41,7 @@ func table_cells(line []rune) []TableCell {
|
|||||||
if end == 0 {
|
if end == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var separator rune
|
separator := table_separator(line[:end])
|
||||||
for _, candidate := range []rune{',', '|', ';', '\t'} {
|
|
||||||
if strings.ContainsRune(string(line[:end]), candidate) {
|
|
||||||
separator = candidate
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var cells []TableCell
|
var cells []TableCell
|
||||||
appendCell := func(start, end int) {
|
appendCell := func(start, end int) {
|
||||||
for start < end && unicode.IsSpace(line[start]) {
|
for start < end && unicode.IsSpace(line[start]) {
|
||||||
@@ -93,13 +86,15 @@ func (r *Review) Set_table_mode(enabled bool) {
|
|||||||
}
|
}
|
||||||
r.tableMode = enabled
|
r.tableMode = enabled
|
||||||
r.tableHeaders = nil
|
r.tableHeaders = nil
|
||||||
|
r.tableLayout = tableLayout{}
|
||||||
|
r.tableManual, r.tableAutoDisabled = false, false
|
||||||
if enabled && r.ready {
|
if enabled && r.ready {
|
||||||
r.sync_table_column()
|
r.sync_table_column()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Review) 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
|
r.tableColumn = 0
|
||||||
for index, cell := range cells {
|
for index, cell := range cells {
|
||||||
if r.x < cell.start {
|
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 {
|
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 {
|
if len(cells) == 0 {
|
||||||
return presentation.Message{Text: i18n.T("could not set header row")}
|
return presentation.Message{Text: i18n.T("could not set header row")}
|
||||||
}
|
}
|
||||||
r.tableHeaders = nil
|
r.tableHeaders = nil
|
||||||
|
r.tableLayout, r.tableManual = layout, true
|
||||||
for _, cell := range cells {
|
for _, cell := range cells {
|
||||||
r.tableHeaders = append(r.tableHeaders, string(r.screen.Line(r.y)[cell.start:cell.end]))
|
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:
|
default:
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
cells := table_cells(r.screen.Line(r.y))
|
cells := r.current_table_cells()
|
||||||
if len(cells) == 0 {
|
if len(cells) == 0 {
|
||||||
return i18n.T("no table data"), true
|
return i18n.T("no table data"), true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ func (r *Review) vertical_character(direction int) string {
|
|||||||
// clamp the column. Table review also retains the selected cell: an
|
// clamp the column. Table review also retains the selected cell: an
|
||||||
// absolute column outside it must not read a neighbouring cell instead.
|
// absolute column outside it must not read a neighbouring cell instead.
|
||||||
if r.tableMode {
|
if r.tableMode {
|
||||||
cells := table_cells(r.screen.Line(r.y))
|
cells := r.current_table_cells()
|
||||||
if r.tableColumn >= len(cells) {
|
if r.tableColumn >= len(cells) {
|
||||||
return i18n.T("no table data")
|
return i18n.T("no table data")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user