170 lines
4.8 KiB
Go
170 lines
4.8 KiB
Go
// Copyright (c) 2026 Storm Dragon
|
|
// Authors: Storm Dragon, Angus Kola
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
package ocr
|
|
|
|
import (
|
|
"sort"
|
|
"strings"
|
|
)
|
|
|
|
type Unit uint8
|
|
|
|
const (
|
|
Character Unit = iota + 1
|
|
WordUnit
|
|
LineUnit
|
|
PageUnit
|
|
LineStart
|
|
LineEnd
|
|
DocumentStart
|
|
DocumentEnd
|
|
)
|
|
|
|
// Cursor owns a virtual rune offset. It has no native accessible identity and
|
|
// never moves application focus. A session discards it on retirement.
|
|
type Cursor struct {
|
|
result Result
|
|
text []rune
|
|
lines []Span
|
|
lineTexts []string
|
|
offset, column int
|
|
}
|
|
|
|
type Position struct {
|
|
Offset int
|
|
Text string
|
|
Line string
|
|
LineStart int
|
|
Cursor int // Rune offset within Line, including its end position.
|
|
Moved bool
|
|
}
|
|
|
|
type ReadLine struct {
|
|
Offset int
|
|
Text string
|
|
}
|
|
|
|
func NewCursor(result Result) *Cursor {
|
|
cursor := &Cursor{result: result, text: []rune(result.Text), lineTexts: strings.Split(result.Text, "\n")}
|
|
start := 0
|
|
for index, char := range cursor.text {
|
|
if char == '\n' {
|
|
cursor.lines = append(cursor.lines, Span{start, index})
|
|
start = index + 1
|
|
}
|
|
}
|
|
cursor.lines = append(cursor.lines, Span{start, len(cursor.text)})
|
|
return cursor
|
|
}
|
|
|
|
func (cursor *Cursor) line_index() int {
|
|
return sort.Search(len(cursor.lines), func(index int) bool { return cursor.lines[index].End >= cursor.offset })
|
|
}
|
|
|
|
func (cursor *Cursor) Current(unit Unit) Position {
|
|
lineIndex := cursor.line_index()
|
|
line := cursor.lines[lineIndex]
|
|
position := Position{Offset: cursor.offset, Line: cursor.lineTexts[lineIndex], LineStart: line.Start, Cursor: cursor.offset - line.Start}
|
|
switch unit {
|
|
case Character, LineStart, LineEnd:
|
|
if cursor.offset < len(cursor.text) {
|
|
position.Text = string(cursor.text[cursor.offset])
|
|
}
|
|
case WordUnit:
|
|
if word, ok := cursor.result.WordAt(cursor.offset); ok {
|
|
position.Text = word.Text
|
|
}
|
|
default:
|
|
position.Text = position.Line
|
|
}
|
|
return position
|
|
}
|
|
|
|
func (cursor *Cursor) Move(unit Unit, step int) Position {
|
|
previous := cursor.offset
|
|
lineIndex := cursor.line_index()
|
|
line := cursor.lines[lineIndex]
|
|
switch unit {
|
|
case Character:
|
|
cursor.offset = max(0, min(len(cursor.text), cursor.offset+step))
|
|
case WordUnit:
|
|
if step > 0 {
|
|
index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start > cursor.offset })
|
|
if index < len(cursor.result.Words) {
|
|
cursor.offset = cursor.result.Words[index].Start
|
|
}
|
|
} else if step < 0 {
|
|
index := sort.Search(len(cursor.result.Words), func(index int) bool { return cursor.result.Words[index].Start >= cursor.offset }) - 1
|
|
if index >= 0 {
|
|
cursor.offset = cursor.result.Words[index].Start
|
|
}
|
|
}
|
|
case LineUnit, PageUnit:
|
|
if unit == PageUnit {
|
|
step *= 20
|
|
}
|
|
next := max(0, min(len(cursor.lines)-1, lineIndex+step))
|
|
if next != lineIndex {
|
|
cursor.offset = cursor.lines[next].Start + min(cursor.column, cursor.lines[next].End-cursor.lines[next].Start)
|
|
}
|
|
case LineStart:
|
|
cursor.offset = line.Start
|
|
case LineEnd:
|
|
cursor.offset = line.End
|
|
case DocumentStart:
|
|
cursor.offset = 0
|
|
case DocumentEnd:
|
|
cursor.offset = len(cursor.text)
|
|
}
|
|
if unit != LineUnit && unit != PageUnit {
|
|
cursor.column = cursor.offset - cursor.lines[cursor.line_index()].Start
|
|
}
|
|
position := cursor.Current(unit)
|
|
position.Moved = previous != cursor.offset
|
|
return position
|
|
}
|
|
|
|
// Set moves only the virtual cursor. It rejects offsets outside the recognized
|
|
// text so a stale routing request cannot produce an invalid position.
|
|
func (cursor *Cursor) Set(offset int) (Position, bool) {
|
|
if offset < 0 || offset > len(cursor.text) {
|
|
return Position{}, false
|
|
}
|
|
cursor.offset = offset
|
|
cursor.column = offset - cursor.lines[cursor.line_index()].Start
|
|
return cursor.Current(Character), true
|
|
}
|
|
|
|
func (cursor *Cursor) Length() int { return len(cursor.text) }
|
|
func (cursor *Cursor) Offset() int { return cursor.offset }
|
|
func (cursor *Cursor) Text() string { return cursor.result.Text }
|
|
func (cursor *Cursor) CurrentWord() (Word, bool) { return cursor.result.WordAt(cursor.offset) }
|
|
|
|
func (cursor *Cursor) Slice(start, end int) (string, bool) {
|
|
if start < 0 || end < start || end > len(cursor.text) {
|
|
return "", false
|
|
}
|
|
return string(cursor.text[start:end]), true
|
|
}
|
|
|
|
// LinesFrom returns whole lines after the virtual cursor. The first line
|
|
// begins at the cursor position; following lines retain recognition order.
|
|
func (cursor *Cursor) LinesFrom() []ReadLine {
|
|
index := cursor.line_index()
|
|
first := index
|
|
parts := make([]ReadLine, 0, len(cursor.lines)-index)
|
|
for ; index < len(cursor.lines); index++ {
|
|
line := cursor.lines[index]
|
|
start := line.Start
|
|
if index == first {
|
|
start = cursor.offset
|
|
}
|
|
if start < line.End {
|
|
parts = append(parts, ReadLine{Offset: start, Text: string(cursor.text[start:line.End])})
|
|
}
|
|
}
|
|
return parts
|
|
}
|