Files

263 lines
8.6 KiB
Go

// Copyright (c) 2026 Storm Dragon
// SPDX-License-Identifier: GPL-3.0-or-later
package reader
import (
"fmt"
"slices"
"strconv"
"strings"
"git.stormux.org/storm/fenrir/internal/command"
"git.stormux.org/storm/fenrir/internal/i18n"
"git.stormux.org/storm/fenrir/internal/presentation"
"git.stormux.org/storm/fenrir/internal/screen"
)
type terminalRegions struct {
window *screen.Region
live [10]*screen.Region
}
var readLiveRegions = [...]command.ID{
command.ReadLiveRegion1, command.ReadLiveRegion2, command.ReadLiveRegion3,
command.ReadLiveRegion4, command.ReadLiveRegion5, command.ReadLiveRegion6,
command.ReadLiveRegion7, command.ReadLiveRegion8, command.ReadLiveRegion9, command.ReadLiveRegion10,
}
var clearLiveRegions = [...]command.ID{
command.ClearLiveRegion1, command.ClearLiveRegion2, command.ClearLiveRegion3,
command.ClearLiveRegion4, command.ClearLiveRegion5, command.ClearLiveRegion6,
command.ClearLiveRegion7, command.ClearLiveRegion8, command.ClearLiveRegion9, command.ClearLiveRegion10,
}
func (r *Reader) region_state() *terminalRegions { return r.regions[r.screen.Terminal] }
func (r *Reader) ensure_regions() *terminalRegions {
if r.regions == nil {
r.regions = make(map[int]*terminalRegions)
}
if r.region_state() == nil {
r.regions[r.screen.Terminal] = &terminalRegions{}
}
return r.region_state()
}
func (s *terminalRegions) has_live() bool {
if s != nil {
for _, region := range s.live {
if region != nil {
return true
}
}
}
return false
}
func (s *terminalRegions) allows(x, y, cols int, window, live bool) bool {
if s == nil {
return true
}
if window && s.window != nil && !s.window.Contains(x, y, cols) {
return false
}
if live {
for _, region := range s.live {
if region != nil && region.Contains(x, y, cols) {
return false
}
}
}
return true
}
// filtered_snapshot never alters the source snapshot. Masking before diffing
// keeps excluded churn out of typing/scroll classification, rather than trying
// to remove strings from an already flattened presentation.
func (s *terminalRegions) filtered_snapshot(snapshot screen.Snapshot, window, live bool) screen.Snapshot {
if s == nil || (!window || s.window == nil) && (!live || !s.has_live()) {
return snapshot
}
snapshot.Cells = slices.Clone(snapshot.Cells)
snapshot.Attributes = slices.Clone(snapshot.Attributes)
for row := range snapshot.Rows {
for col := range snapshot.Cols {
if s.allows(col, row, snapshot.Cols, window, live) {
continue
}
index := row*snapshot.Cols + col
snapshot.Cells[index] = ' '
if len(snapshot.Attributes) == len(snapshot.Cells) {
snapshot.Attributes[index] = screen.Attribute{}
}
}
}
return snapshot
}
// Automatic_snapshot is also used by prompt restoration: an excluded update
// must not restore speech or bypass the automatic-reading filter.
func (r *Reader) Automatic_snapshot() screen.Snapshot {
return r.region_state().filtered_snapshot(r.screen, true, true)
}
func (r *Reader) Region_revision() uint64 { return r.regionRevision }
// live_snapshot retains only live-region cells for progress detection alongside
// a confirmed typing edit. Unlike automatic speech, progress may inspect them.
func (s *terminalRegions) live_snapshot(snapshot screen.Snapshot) screen.Snapshot {
snapshot.Cells = slices.Clone(snapshot.Cells)
for row := range snapshot.Rows {
for col := range snapshot.Cols {
if s.allows(col, row, snapshot.Cols, false, true) {
snapshot.Cells[row*snapshot.Cols+col] = ' '
}
}
}
return snapshot
}
// An explicit live region remains eligible for progress even when it lies
// outside a reading window. The window otherwise excludes outside progress.
func (s *terminalRegions) progress_snapshot(snapshot screen.Snapshot) screen.Snapshot {
if s == nil || s.window == nil {
return snapshot
}
snapshot.Cells = slices.Clone(snapshot.Cells)
for row := range snapshot.Rows {
for col := range snapshot.Cols {
if !s.window.Contains(col, row, snapshot.Cols) && s.allows(col, row, snapshot.Cols, false, true) {
snapshot.Cells[row*snapshot.Cols+col] = ' '
}
}
}
return snapshot
}
func (r *Reader) reset_region_feedback() {
r.regionRevision++
r.progress.Reset()
r.inputCount, r.inputBoundary = 0, false
r.lastCompletedWord = ""
r.attributeKnown, r.indentKnown = false, false
}
// Set_window is shared by marked keyboard commands and validated remote
// coordinates. Regions belong to the terminal, never a guessed process name.
func (r *Reader) Set_window(x1, y1, x2, y2 int) error {
if !r.screen.Active || min(x1, x2, y1, y2) < 0 || max(x1, x2) >= r.screen.Cols || max(y1, y2) >= r.screen.Rows {
return fmt.Errorf("Window coordinates must be inside the active screen")
}
r.ensure_regions().window = &screen.Region{Left: min(x1, x2), Top: min(y1, y2), Right: max(x1, x2), Bottom: max(y1, y2)}
r.reset_region_feedback()
return nil
}
func (r *Reader) Clear_window() {
if state := r.region_state(); state != nil {
state.window = nil
}
r.reset_region_feedback()
}
func (r *Reader) region_command(id command.ID) (presentation.CommandResult, bool) {
readSlot, clearSlot := slices.Index(readLiveRegions[:], id), slices.Index(clearLiveRegions[:], id)
known := readSlot >= 0 || clearSlot >= 0 || id == command.SetWindow || id == command.ClearWindow || id == command.SetLiveRegion ||
id == command.ListReadingRegions || id == command.ClearLiveRegions || id == command.ClearReadingRegions
if !known {
return presentation.CommandResult{}, false
}
result := presentation.CommandResult{}
if !r.screen.Active {
return presentation.CommandResult{Text: i18n.T("no text console"), Cue: presentation.Error}, true
}
state := r.region_state()
switch {
case readSlot >= 0 || clearSlot >= 0:
slot := max(readSlot, clearSlot)
number := strconv.Itoa(slot + 1)
if state == nil || state.live[slot] == nil {
result.Text = i18n.Format("Live region {0} not set", number)
} else if clearSlot >= 0 {
state.live[slot] = nil
r.reset_region_feedback()
result.Text = i18n.Format("Live region {0} cleared", number)
} else {
result.Text = state.live[slot].Text(r.screen)
if state.live[slot].Left >= r.screen.Cols || state.live[slot].Top >= r.screen.Rows {
result.Text = i18n.Format("Live region {0} outside screen", number)
} else if strings.TrimSpace(result.Text) == "" {
result.Text = i18n.T("blank")
}
}
case id == command.SetLiveRegion:
region, ok := r.review.Marked_region(true)
if !ok {
result.Text = i18n.T("One or two marks are needed")
break
}
state = r.ensure_regions()
slot := slices.Index(state.live[:], nil)
if slot < 0 {
result.Text = i18n.T("All 10 live regions are set")
break
}
state.live[slot] = &region
r.review.Clear_marks()
r.reset_region_feedback()
result.Text = i18n.Format("Live region {0} set", strconv.Itoa(slot+1))
case id == command.SetWindow:
region, ok := r.review.Marked_region(false)
if !ok {
result.Text = i18n.T("Set window begin and end marks")
break
}
r.ensure_regions().window = &region
r.review.Clear_marks()
r.reset_region_feedback()
result.Text = i18n.Format("Window Mode on for terminal {0}", strconv.Itoa(r.screen.Terminal))
case id == command.ClearWindow:
if state == nil || state.window == nil {
result.Text = i18n.T("Not in window Mode")
} else {
r.Clear_window()
result.Text = i18n.Format("Window Mode off for terminal {0}", strconv.Itoa(r.screen.Terminal))
}
case id == command.ClearLiveRegions:
if state != nil {
state.live = [10]*screen.Region{}
}
r.reset_region_feedback()
result.Text = i18n.T("Live regions cleared")
case id == command.ClearReadingRegions:
delete(r.regions, r.screen.Terminal)
r.reset_region_feedback()
result.Text = i18n.T("Reading regions cleared")
case id == command.ListReadingRegions:
var entries []string
if state != nil {
if state.window != nil {
entries = append(entries, i18n.Format("Reading window: {0}", region_description(*state.window)))
}
for slot, region := range state.live {
if region != nil {
entries = append(entries, i18n.Format("Live region {0}: {1}", strconv.Itoa(slot+1), region_description(*region)))
}
}
}
result.Text = strings.Join(entries, ". ")
if len(entries) == 0 {
result.Text = i18n.T("No reading regions set")
}
}
return result, true
}
func region_description(region screen.Region) string {
if region.ToLineEnd {
return i18n.Format("row {0}, column {1} to end of line", strconv.Itoa(region.Top+1), strconv.Itoa(region.Left+1))
}
return i18n.Format("row {0}, column {1} to row {2}, column {3}", strconv.Itoa(region.Top+1), strconv.Itoa(region.Left+1), strconv.Itoa(region.Bottom+1), strconv.Itoa(region.Right+1))
}