// Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // SPDX-License-Identifier: GPL-3.0-or-later package internalui import ( "strconv" "strings" "unicode" "unicode/utf8" ) func (s *Session) characterCount(n int) string { if n == 1 { return s.messages.T("1 character") } return s.messages.Format("{0} characters", strconv.Itoa(n)) } func (s *Session) edit(control *Control, key Key) Result { if key.Name == "enter" { return Result{Action: control.Action} } characters := []rune(control.Text) start, end := min(s.cursor, s.anchor), max(s.cursor, s.anchor) if key.Ctrl && key.Name == "" { switch strings.ToLower(key.Text) { case "a": s.anchor, s.cursor = 0, len(characters) output := s.Presentation() output.Speech = s.messages.T("Selected all") return output case "v": return Result{Paste: true} case "c", "x": if control.Secret { output := s.Presentation() output.Speech = s.messages.T("Copy and cut are unavailable for password fields") return output } if start == end { return Result{} } text := string(characters[start:end]) // Cutting is completed by the host only after clipboard write succeeds. return Result{Copy: &text} default: return Result{} } } if key.Name == "left" || key.Name == "right" || key.Name == "home" || key.Name == "end" { next := s.cursor switch key.Name { case "left": if !key.Shift && start != end { next = start } else { next = max(0, next-1) if key.Ctrl { for next > 0 && next < len(characters) && unicode.IsSpace(characters[next]) { next-- } for next > 0 && !unicode.IsSpace(characters[next-1]) { next-- } } } case "right": if !key.Shift && start != end { next = end } else { next = min(len(characters), next+1) if key.Ctrl { for next < len(characters) && !unicode.IsSpace(characters[next]) { next++ } for next < len(characters) && unicode.IsSpace(characters[next]) { next++ } } } case "home": next = 0 case "end": next = len(characters) } s.cursor = next if !key.Shift { s.anchor = next } output := s.Presentation() if next == len(characters) { output.Speech = s.messages.T("End of text") } else if control.Secret { output.Speech = s.messages.T("Hidden character") } else { output.Speech = string(characters[next]) } if key.Shift { output.Speech = s.messages.Format("{0}, selected", output.Speech) } return output } if key.Name == "backspace" || key.Name == "delete" { if start == end { if key.Name == "backspace" { start = max(0, start-1) } else { end = min(len(characters), end+1) } } if start == end { return Result{} } removed := string(characters[start:end]) control.Text = string(append(characters[:start], characters[end:]...)) s.cursor, s.anchor = start, start output := s.Presentation() if control.Secret { output.Speech = s.messages.Format("Deleted {0}", s.characterCount(end-start)) } else { output.Speech = s.messages.Format("Deleted {0}", removed) } return output } if (key.Name == "" || key.Name == "space") && key.Text != "" && !key.Ctrl { return s.Insert(key.Text) } return Result{} } // Insert handles typing and paste atomically; invalid/oversized input leaves // the field and selection untouched. func (s *Session) Insert(text string) Result { if s.closed { return Result{} } if s.focus < 0 { return Result{} } control := &s.form.Tabs[s.tab].Controls[s.focus] if control.Kind != Entry { return Result{} } if !utf8.ValidString(text) { return Result{Speech: s.messages.T("Invalid text"), Braille: s.messages.T("Invalid text"), Cursor: -1} } characters := []rune(control.Text) start, end := min(s.cursor, s.anchor), max(s.cursor, s.anchor) inserted := []rune(text) for _, character := range inserted { if unicode.IsControl(character) { return Result{Speech: s.messages.T("Only single-line text is accepted"), Braille: s.messages.T("Only single-line text is accepted"), Cursor: -1} } } if len(characters)-(end-start)+len(inserted) > control.MaxLength { return Result{Speech: s.messages.T("Text limit reached"), Braille: s.messages.T("Text limit reached"), Cursor: -1} } control.Text = string(characters[:start]) + text + string(characters[end:]) s.cursor, s.anchor = start+len(inserted), start+len(inserted) output := s.Presentation() if control.Secret { output.Speech = s.messages.Format("Inserted {0}", s.characterCount(len(inserted))) } else { output.Speech = text } return output }