Files

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
}