228 lines
6.2 KiB
Go
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
|
|
}
|