// 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 }