Files

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
}
}