251 lines
6.7 KiB
Go
251 lines
6.7 KiB
Go
// 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
|
|
}
|
|
}
|