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