// Fenrir console screen reader. // Copyright (c) 2026 Storm Dragon // SPDX-License-Identifier: GPL-3.0-or-later // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package review import ( "strconv" "unicode" "git.stormux.org/storm/fenrir/internal/command" "git.stormux.org/storm/fenrir/internal/i18n" "git.stormux.org/storm/fenrir/internal/presentation" ) type TableCell struct { start, end int // trimmed, half-open Unicode cell bounds; empty cells retained } // Match Python's delimiter preference and two-or-more-space aligned columns. // Bounds come directly from screen positions, never byte offsets or searches // for cell text (which can repeat within a row). func table_cells(line []rune) []TableCell { end := len(line) for end > 0 && (line[end-1] == ' ' || line[end-1] == 0) { end-- } if end == 0 { return nil } separator := table_separator(line[:end]) var cells []TableCell appendCell := func(start, end int) { for start < end && unicode.IsSpace(line[start]) { start++ } for end > start && unicode.IsSpace(line[end-1]) { end-- } cells = append(cells, TableCell{start: start, end: end}) } start := 0 for index := 0; index < end; index++ { if separator != 0 { if line[index] == separator { appendCell(start, index) start = index + 1 } continue } if line[index] != ' ' { continue } gap := index for index+1 < end && line[index+1] == ' ' { index++ } if index-gap >= 1 { // Leading indentation is not an empty aligned column. if gap > start { appendCell(start, gap) } start = index + 1 } } appendCell(start, end) return cells } func (r *Review) Set_table_mode(enabled bool) { if r.tableMode == enabled { return } 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 := r.current_table_cells() r.tableColumn = 0 for index, cell := range cells { if r.x < cell.start { break } r.tableColumn = index } } func (r *Review) column_header() string { if r.tableColumn < len(r.tableHeaders) && r.tableHeaders[r.tableColumn] != "" { return r.tableHeaders[r.tableColumn] } // Python's generic column label is not a gettext message ID. return "Column " + strconv.Itoa(r.tableColumn+1) } func (r *Review) table_cell_text(cells []TableCell) string { cell := cells[r.tableColumn] text := string(r.screen.Line(r.y)[cell.start:cell.end]) if text == "" { text = i18n.T("blank") } return text + " " + r.column_header() } func (r *Review) set_table_headers() presentation.Message { 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])) } return presentation.Message{Text: i18n.T("header row set"), Cue: presentation.PlaceStartMark} } func (r *Review) table_command(id command.ID) (string, bool) { switch id { case command.CurrentWord, command.PreviousWord, command.NextWord, command.LineStart, command.LineEnd, command.CurrentChar, command.PreviousChar, command.NextChar, command.FirstText, command.LastText: default: return "", false } cells := r.current_table_cells() if len(cells) == 0 { return i18n.T("no table data"), true } if id == command.LineStart { r.tableColumn = 0 } else if id == command.LineEnd { r.tableColumn = len(cells) - 1 } if r.tableColumn >= len(cells) { return i18n.T("no table data"), true } switch id { case command.CurrentWord: return r.table_cell_text(cells), true case command.PreviousWord, command.NextWord, command.LineStart, command.LineEnd: boundary := "" switch id { case command.PreviousWord: if r.tableColumn == 0 { boundary = i18n.T("start of line") r.result.After.Cue = presentation.LineStart } else { r.tableColumn-- } case command.NextWord: if r.tableColumn == len(cells)-1 { boundary = i18n.T("end of line") r.result.After.Cue = presentation.LineEnd } else { r.tableColumn++ } case command.LineStart: r.tableColumn = 0 case command.LineEnd: r.tableColumn = len(cells) - 1 } r.x = min(cells[r.tableColumn].start, r.screen.Cols-1) text := r.table_cell_text(cells) if boundary != "" { r.result.After.Text = boundary } else if id == command.LineStart { r.result.After.Text = i18n.T("first cell") } else if id == command.LineEnd { r.result.After.Text = i18n.T("last cell") } return text, true } cell := cells[r.tableColumn] first, last := min(cell.start, r.screen.Cols-1), max(cell.start, cell.end-1) last = min(last, r.screen.Cols-1) if r.x < first || r.x > last { r.x = first } character := func() string { if cell.start == cell.end { r.result.Cue = presentation.Blank return i18n.T("blank") } return r.currentCharacter() } switch id { case command.PreviousChar: if r.x == first { r.result.After.Text, r.result.After.Cue = i18n.T("start of cell"), presentation.LineStart return character(), true } r.x-- case command.NextChar: if r.x == last { r.result.After.Text, r.result.After.Cue = i18n.T("end of cell"), presentation.LineEnd return character(), true } r.x++ case command.FirstText: r.x = first return character() + " " + i18n.Format("first character in cell {0}", r.column_header()), true case command.LastText: r.x = last return character() + " " + i18n.Format("last character in cell {0}", r.column_header()), true } return character(), true }