mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-15 16:42:46 +00:00
fix: insert CSI u handler into model.go + add tui tests
The previous commit defined parseCSIuBytes but failed to insert the handler into Update(). Now properly inserted before the textinput fallback. Add 10 test cases for parseCSIuBytes covering ctrl+alt+f/c/ m/p/h, ctrl+shift+c, alt+c, and invalid sequences. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+308
-286
@@ -1,286 +1,308 @@
|
|||||||
package tui
|
package tui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/textinput"
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
"github.com/charmbracelet/bubbles/viewport"
|
"github.com/charmbracelet/bubbles/viewport"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
||||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
||||||
)
|
)
|
||||||
|
|
||||||
type doneMsg struct{}
|
type doneMsg struct{}
|
||||||
|
|
||||||
type modeItem struct {
|
type modeItem struct {
|
||||||
key string
|
key string
|
||||||
label string
|
label string
|
||||||
value string
|
value string
|
||||||
rawValue string
|
rawValue string
|
||||||
}
|
}
|
||||||
|
|
||||||
type panelLine struct {
|
type panelLine struct {
|
||||||
text string
|
text string
|
||||||
selected bool
|
selected bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
type Model struct {
|
||||||
App *app.App
|
App *app.App
|
||||||
|
|
||||||
viewport viewport.Model
|
viewport viewport.Model
|
||||||
input textinput.Model
|
input textinput.Model
|
||||||
|
|
||||||
ready bool
|
ready bool
|
||||||
width int
|
width int
|
||||||
height int
|
height int
|
||||||
statusLine string
|
statusLine string
|
||||||
suggestions []string
|
suggestions []string
|
||||||
content strings.Builder
|
content strings.Builder
|
||||||
followTail bool
|
followTail bool
|
||||||
|
|
||||||
showModal bool
|
showModal bool
|
||||||
modalTitle string
|
modalTitle string
|
||||||
modalBody string
|
modalBody string
|
||||||
|
|
||||||
panelKind event.UIPanelKind
|
panelKind event.UIPanelKind
|
||||||
panelIndex int
|
panelIndex int
|
||||||
panelError string
|
panelError string
|
||||||
|
|
||||||
forwardItems []forward.Snapshot
|
forwardItems []forward.Snapshot
|
||||||
pluginItems []luaplugin.Snapshot
|
pluginItems []luaplugin.Snapshot
|
||||||
modeItems []modeItem
|
modeItems []modeItem
|
||||||
|
|
||||||
promptActive bool
|
promptActive bool
|
||||||
promptTitle string
|
promptTitle string
|
||||||
promptHint string
|
promptHint string
|
||||||
promptInput textinput.Model
|
promptInput textinput.Model
|
||||||
promptSubmit func(string)
|
promptSubmit func(string)
|
||||||
|
|
||||||
formActive bool
|
formActive bool
|
||||||
formTitle string
|
formTitle string
|
||||||
formFields []textinput.Model
|
formFields []textinput.Model
|
||||||
formLabels []string
|
formLabels []string
|
||||||
formFocus int
|
formFocus int
|
||||||
formSubmit func([]string)
|
formSubmit func([]string)
|
||||||
|
|
||||||
completionActive bool
|
completionActive bool
|
||||||
completionBase string
|
completionBase string
|
||||||
completionCandidates []string
|
completionCandidates []string
|
||||||
completionIndex int
|
completionIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(application *app.App) *Model {
|
func New(application *app.App) *Model {
|
||||||
in := textinput.New()
|
in := textinput.New()
|
||||||
in.Placeholder = "Type to send to remote, use .help for commands"
|
in.Placeholder = "Type to send to remote, use .help for commands"
|
||||||
in.Focus()
|
in.Focus()
|
||||||
in.CharLimit = 0
|
in.CharLimit = 0
|
||||||
in.Prompt = "> "
|
in.Prompt = "> "
|
||||||
in.Width = 80
|
in.Width = 80
|
||||||
|
|
||||||
return &Model{App: application, input: in, followTail: true}
|
return &Model{App: application, input: in, followTail: true}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Init() tea.Cmd {
|
func (m *Model) Init() tea.Cmd {
|
||||||
return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink)
|
return tea.Batch(waitUIEvent(m.App.UIEvents()), waitDone(m.App.WaitDone()), textinput.Blink)
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
|
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
ev, ok := <-ch
|
ev, ok := <-ch
|
||||||
if !ok {
|
if !ok {
|
||||||
return doneMsg{}
|
return doneMsg{}
|
||||||
}
|
}
|
||||||
return ev
|
return ev
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func waitDone(ch <-chan struct{}) tea.Cmd {
|
func waitDone(ch <-chan struct{}) tea.Cmd {
|
||||||
return func() tea.Msg {
|
return func() tea.Msg {
|
||||||
<-ch
|
<-ch
|
||||||
return doneMsg{}
|
return doneMsg{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
switch msg := msg.(type) {
|
switch msg := msg.(type) {
|
||||||
case doneMsg:
|
case doneMsg:
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
|
|
||||||
case event.UIEvent:
|
case event.UIEvent:
|
||||||
switch msg.Kind {
|
switch msg.Kind {
|
||||||
case event.UIEventOutput, event.UIEventStatus:
|
case event.UIEventOutput, event.UIEventStatus:
|
||||||
if msg.Kind == event.UIEventOutput {
|
if msg.Kind == event.UIEventOutput {
|
||||||
m.appendOutput(msg.Text)
|
m.appendOutput(msg.Text)
|
||||||
} else {
|
} else {
|
||||||
m.statusLine = msg.Text
|
m.statusLine = msg.Text
|
||||||
}
|
}
|
||||||
case event.UIEventModal:
|
case event.UIEventModal:
|
||||||
m.showModal = true
|
m.showModal = true
|
||||||
m.panelKind = event.UIPanelNone
|
m.panelKind = event.UIPanelNone
|
||||||
m.modalTitle = msg.Title
|
m.modalTitle = msg.Title
|
||||||
m.modalBody = msg.Text
|
m.modalBody = msg.Text
|
||||||
m.promptActive = false
|
m.promptActive = false
|
||||||
case event.UIEventPanel:
|
case event.UIEventPanel:
|
||||||
m.openPanel(msg.Panel)
|
m.openPanel(msg.Panel)
|
||||||
}
|
}
|
||||||
return m, waitUIEvent(m.App.UIEvents())
|
return m, waitUIEvent(m.App.UIEvents())
|
||||||
|
|
||||||
case tea.WindowSizeMsg:
|
case tea.WindowSizeMsg:
|
||||||
m.width = msg.Width
|
m.width = msg.Width
|
||||||
m.height = msg.Height
|
m.height = msg.Height
|
||||||
inputHeight := 3
|
inputHeight := 3
|
||||||
statusHeight := 2
|
statusHeight := 2
|
||||||
viewportHeight := msg.Height - inputHeight - statusHeight
|
viewportHeight := msg.Height - inputHeight - statusHeight
|
||||||
if viewportHeight < 3 {
|
if viewportHeight < 3 {
|
||||||
viewportHeight = 3
|
viewportHeight = 3
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.ready {
|
if !m.ready {
|
||||||
m.viewport = viewport.New(msg.Width, viewportHeight)
|
m.viewport = viewport.New(msg.Width, viewportHeight)
|
||||||
m.viewport.YPosition = 0
|
m.viewport.YPosition = 0
|
||||||
m.viewport.SetContent(m.content.String())
|
m.viewport.SetContent(m.content.String())
|
||||||
m.ready = true
|
m.ready = true
|
||||||
} else {
|
} else {
|
||||||
m.viewport.Width = msg.Width
|
m.viewport.Width = msg.Width
|
||||||
m.viewport.Height = viewportHeight
|
m.viewport.Height = viewportHeight
|
||||||
}
|
}
|
||||||
|
|
||||||
m.input.Width = msg.Width - 4
|
m.input.Width = msg.Width - 4
|
||||||
m.viewport.GotoBottom()
|
m.viewport.GotoBottom()
|
||||||
m.followTail = true
|
m.followTail = true
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case tea.KeyMsg:
|
case tea.KeyMsg:
|
||||||
keyStr := strings.ToLower(msg.String())
|
keyStr := strings.ToLower(msg.String())
|
||||||
if m.handleViewportKey(msg) {
|
if m.handleViewportKey(msg) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if keyStr != "tab" && keyStr != "shift+tab" {
|
if keyStr != "tab" && keyStr != "shift+tab" {
|
||||||
m.resetCompletion()
|
m.resetCompletion()
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.showModal {
|
if m.showModal {
|
||||||
handled, cmd := m.handleModalKey(msg)
|
handled, cmd := m.handleModalKey(msg)
|
||||||
if handled {
|
if handled {
|
||||||
return m, cmd
|
return m, cmd
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.isLocalHotkey(keyStr, "c") {
|
if m.isLocalHotkey(keyStr, "c") {
|
||||||
m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)))
|
m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)))
|
||||||
m.App.Close()
|
m.App.Close()
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
|
|
||||||
if handleLocalHotkey(m, keyStr) {
|
if handleLocalHotkey(m, keyStr) {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if keyStr == "ctrl+h" {
|
if keyStr == "ctrl+h" {
|
||||||
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if letter, ok := parseCtrlKey(keyStr); ok {
|
if letter, ok := parseCtrlKey(keyStr); ok {
|
||||||
if err := m.App.SendCtrl(letter); err != nil {
|
if err := m.App.SendCtrl(letter); err != nil {
|
||||||
m.App.Notifyf("[remote] ctrl send failed: %v", err)
|
m.App.Notifyf("[remote] ctrl send failed: %v", err)
|
||||||
}
|
}
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
switch keyStr {
|
switch keyStr {
|
||||||
case "f1":
|
case "f1":
|
||||||
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h"))
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "tab", "shift+tab":
|
case "tab", "shift+tab":
|
||||||
direction := 1
|
direction := 1
|
||||||
if keyStr == "shift+tab" {
|
if keyStr == "shift+tab" {
|
||||||
direction = -1
|
direction = -1
|
||||||
}
|
}
|
||||||
|
|
||||||
if m.completionActive && len(m.completionCandidates) > 0 {
|
if m.completionActive && len(m.completionCandidates) > 0 {
|
||||||
m.stepCompletion(direction)
|
m.stepCompletion(direction)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
line, cands := m.App.Dispatcher().Complete(m.input.Value())
|
line, cands := m.App.Dispatcher().Complete(m.input.Value())
|
||||||
m.suggestions = cands
|
m.suggestions = cands
|
||||||
if len(cands) == 0 {
|
if len(cands) == 0 {
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
if len(cands) == 1 {
|
if len(cands) == 1 {
|
||||||
m.input.SetValue(line)
|
m.input.SetValue(line)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
m.completionActive = true
|
m.completionActive = true
|
||||||
m.completionBase = completionBase(m.input.Value())
|
m.completionBase = completionBase(m.input.Value())
|
||||||
m.completionCandidates = append([]string(nil), cands...)
|
m.completionCandidates = append([]string(nil), cands...)
|
||||||
if direction < 0 {
|
if direction < 0 {
|
||||||
m.completionIndex = len(cands) - 1
|
m.completionIndex = len(cands) - 1
|
||||||
} else {
|
} else {
|
||||||
m.completionIndex = 0
|
m.completionIndex = 0
|
||||||
}
|
}
|
||||||
m.applyCompletion()
|
m.applyCompletion()
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case "enter":
|
case "enter":
|
||||||
line := m.input.Value()
|
line := m.input.Value()
|
||||||
m.input.SetValue("")
|
m.input.SetValue("")
|
||||||
m.suggestions = nil
|
m.suggestions = nil
|
||||||
m.followTail = true
|
m.followTail = true
|
||||||
m.App.HandleLine(line)
|
m.App.HandleLine(line)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var cmd tea.Cmd
|
|
||||||
m.input, cmd = m.input.Update(msg)
|
// Handle CSI u sequences that bubbletea does not parse into KeyMsg
|
||||||
return m, cmd
|
if b, ok := msg.([]byte); ok {
|
||||||
}
|
if key, ok2 := parseCSIuBytes(b); ok2 {
|
||||||
|
keyStr := strings.ToLower(key)
|
||||||
func (m *Model) View() string {
|
if m.showModal {
|
||||||
if !m.ready {
|
last := rune(key[len(key)-1])
|
||||||
return "Initializing..."
|
fake := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{last}, Alt: strings.Contains(key, "alt+")}
|
||||||
}
|
if handled, _ := m.handleModalKey(fake); handled {
|
||||||
|
return m, nil
|
||||||
suggest := "Tab: no candidates"
|
}
|
||||||
if len(m.suggestions) > 1 {
|
}
|
||||||
suggest = "Tab candidates: " + strings.Join(m.suggestions, " ")
|
if keyStr == normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)+"+c" {
|
||||||
} else if len(m.suggestions) == 1 {
|
m.App.Close()
|
||||||
suggest = "Tab: " + m.suggestions[0]
|
return m, tea.Quit
|
||||||
}
|
}
|
||||||
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
|
if handleLocalHotkey(m, keyStr) {
|
||||||
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
|
return m, nil
|
||||||
hotkeys = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("244")).Render(hotkeys)
|
}
|
||||||
status := m.statusLine
|
}
|
||||||
if status == "" {
|
}
|
||||||
status = "Ready"
|
|
||||||
}
|
var cmd tea.Cmd
|
||||||
status = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render(status)
|
m.input, cmd = m.input.Update(msg)
|
||||||
suggest = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(suggest)
|
return m, cmd
|
||||||
base := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.viewport.View(), suggest, status, m.input.View(), hotkeys)
|
}
|
||||||
if !m.showModal {
|
|
||||||
return fillScreen(m.width, m.height, base)
|
func (m *Model) View() string {
|
||||||
}
|
if !m.ready {
|
||||||
|
return "Initializing..."
|
||||||
if m.formActive {
|
}
|
||||||
return renderCenteredModalContent(m.width, m.height, m.renderForm())
|
|
||||||
}
|
suggest := "Tab: no candidates"
|
||||||
|
if len(m.suggestions) > 1 {
|
||||||
if m.promptActive {
|
suggest = "Tab candidates: " + strings.Join(m.suggestions, " ")
|
||||||
return renderCenteredModalContent(m.width, m.height, m.renderPrompt())
|
} else if len(m.suggestions) == 1 {
|
||||||
}
|
suggest = "Tab: " + m.suggestions[0]
|
||||||
|
}
|
||||||
if m.panelKind != event.UIPanelNone {
|
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
|
||||||
return renderCenteredModalContent(m.width, m.height, m.renderPanel())
|
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
|
||||||
}
|
hotkeys = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("244")).Render(hotkeys)
|
||||||
|
status := m.statusLine
|
||||||
return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody)
|
if status == "" {
|
||||||
}
|
status = "Ready"
|
||||||
|
}
|
||||||
|
status = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("255")).Render(status)
|
||||||
|
suggest = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39")).Render(suggest)
|
||||||
|
base := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.viewport.View(), suggest, status, m.input.View(), hotkeys)
|
||||||
|
if !m.showModal {
|
||||||
|
return fillScreen(m.width, m.height, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.formActive {
|
||||||
|
return renderCenteredModalContent(m.width, m.height, m.renderForm())
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.promptActive {
|
||||||
|
return renderCenteredModalContent(m.width, m.height, m.renderPrompt())
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.panelKind != event.UIPanelNone {
|
||||||
|
return renderCenteredModalContent(m.width, m.height, m.renderPanel())
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestParseCSIuBytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
seq []byte
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{name: "ctrl+alt+f", seq: []byte{0x1b, '[', '1', '0', '2', ';', '6', 'u'}, want: "ctrl+alt+f", ok: true},
|
||||||
|
{name: "ctrl+alt+c", seq: []byte{0x1b, '[', '9', '9', ';', '6', 'u'}, want: "ctrl+alt+c", ok: true},
|
||||||
|
{name: "ctrl+alt+m", seq: []byte{0x1b, '[', '1', '0', '9', ';', '6', 'u'}, want: "ctrl+alt+m", ok: true},
|
||||||
|
{name: "ctrl+alt+p", seq: []byte{0x1b, '[', '1', '1', '2', ';', '6', 'u'}, want: "ctrl+alt+p", ok: true},
|
||||||
|
{name: "ctrl+alt+h", seq: []byte{0x1b, '[', '1', '0', '4', ';', '6', 'u'}, want: "ctrl+alt+h", ok: true},
|
||||||
|
{name: "ctrl+shift+c", seq: []byte{0x1b, '[', '9', '9', ';', '5', 'u'}, want: "ctrl+shift+c", ok: true},
|
||||||
|
{name: "alt+c (no ctrl)", seq: []byte{0x1b, '[', '9', '9', ';', '2', 'u'}, want: "alt+c", ok: true},
|
||||||
|
{name: "plain c", seq: []byte{0x1b, '[', '9', '9', ';', '0', 'u'}, want: "c", ok: true},
|
||||||
|
{name: "not CSI u", seq: []byte{0x1b, '[', 'A'}, want: "", ok: false},
|
||||||
|
{name: "empty", seq: []byte{}, want: "", ok: false},
|
||||||
|
{name: "no escape", seq: []byte("hello"), want: "", ok: false},
|
||||||
|
{name: "ESC [ A (arrow up)", seq: []byte{0x1b, '[', 'A'}, want: "", ok: false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, ok := parseCSIuBytes(tt.seq)
|
||||||
|
if ok != tt.ok || got != tt.want {
|
||||||
|
t.Fatalf("parseCSIuBytes(%v): got=(%q,%v) want=(%q,%v)", tt.seq, got, ok, tt.want, tt.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user