Files

228 lines
6.2 KiB
Go

// 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 <https://www.gnu.org/licenses/gpl-3.0.html>.
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
}