// 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] = ®ion 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 = ®ion 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)) }