mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-15 16:42:46 +00:00
9bfb664838
- Remove duplicate labels from renderForm (textinput.Prompt already shows label) - Pass modifier key combos through form handler to global hotkey handlers - Update forward form footer for clarity Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
505 lines
12 KiB
Go
505 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
)
|
|
|
|
func (m *Model) handleModalKey(msg tea.KeyMsg) (bool, tea.Cmd) {
|
|
keyStr := strings.ToLower(msg.String())
|
|
|
|
if m.formActive {
|
|
return m.handleFormKey(msg)
|
|
}
|
|
if m.promptActive {
|
|
return m.handlePromptKey(msg)
|
|
}
|
|
if keyStr == "esc" {
|
|
m.closeModal()
|
|
return true, nil
|
|
}
|
|
if m.panelKind == event.UIPanelNone {
|
|
if keyStr == "enter" {
|
|
m.closeModal()
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
switch m.panelKind {
|
|
case event.UIPanelForward:
|
|
return m.handleForwardPanelKey(keyStr), nil
|
|
case event.UIPanelPlugin:
|
|
return m.handlePluginPanelKey(keyStr), nil
|
|
case event.UIPanelMode:
|
|
return m.handleModePanelKey(keyStr), nil
|
|
default:
|
|
return true, nil
|
|
}
|
|
}
|
|
|
|
func (m *Model) closeModal() {
|
|
m.showModal = false
|
|
m.panelKind = event.UIPanelNone
|
|
m.modalTitle = ""
|
|
m.modalBody = ""
|
|
m.promptActive = false
|
|
m.promptSubmit = nil
|
|
m.formActive = false
|
|
m.formSubmit = nil
|
|
m.panelError = ""
|
|
}
|
|
|
|
func (m *Model) openPanel(kind event.UIPanelKind) {
|
|
m.showModal = true
|
|
m.panelKind = kind
|
|
m.panelIndex = 0
|
|
m.promptActive = false
|
|
m.promptSubmit = nil
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
|
|
func (m *Model) refreshPanel() {
|
|
switch m.panelKind {
|
|
case event.UIPanelForward:
|
|
m.forwardItems = m.App.Forward().List()
|
|
m.panelIndex = clampIndex(m.panelIndex, len(m.forwardItems))
|
|
case event.UIPanelPlugin:
|
|
m.pluginItems = m.App.Plugins().List()
|
|
m.panelIndex = clampIndex(m.panelIndex, len(m.pluginItems))
|
|
case event.UIPanelMode:
|
|
m.modeItems = m.buildModeItems()
|
|
m.panelIndex = clampIndex(m.panelIndex, len(m.modeItems))
|
|
}
|
|
}
|
|
|
|
func (m *Model) buildModeItems() []modeItem {
|
|
cfg := m.App.Cfg()
|
|
return []modeItem{
|
|
{"in", "Input Charset", cfg.InputCode, cfg.InputCode},
|
|
{"out", "Output Charset", cfg.OutputCode, cfg.OutputCode},
|
|
{"end", "Line End", fmt.Sprintf("%q", cfg.EndStr), cfg.EndStr},
|
|
{"frame", "Hex Frame Size", fmt.Sprintf("%d", cfg.FrameSize), fmt.Sprintf("%d", cfg.FrameSize)},
|
|
{"timestamp", "Timestamp", fmt.Sprintf("%v", cfg.TimesTamp), fmt.Sprintf("%v", cfg.TimesTamp)},
|
|
{"timefmt", "Timestamp Format", cfg.TimesFmt, cfg.TimesFmt},
|
|
}
|
|
}
|
|
|
|
// Forward modes for tab cycling
|
|
var forwardModes = []string{"tcp", "udp", "tcp-s", "udp-s", "com"}
|
|
|
|
func (m *Model) handleForwardPanelKey(key string) bool {
|
|
switch key {
|
|
case "up", "k":
|
|
if m.panelIndex > 0 {
|
|
m.panelIndex--
|
|
}
|
|
return true
|
|
case "down", "j":
|
|
if m.panelIndex < len(m.forwardItems)-1 {
|
|
m.panelIndex++
|
|
}
|
|
return true
|
|
case "r":
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
return true
|
|
case "a":
|
|
m.startForwardForm("Add", "tcp", "")
|
|
return true
|
|
}
|
|
if len(m.forwardItems) == 0 {
|
|
return true
|
|
}
|
|
|
|
sel := m.forwardItems[m.panelIndex]
|
|
switch key {
|
|
case "enter":
|
|
if sel.Enabled {
|
|
_ = m.App.Forward().Disable(sel.ID)
|
|
} else {
|
|
_ = m.App.Forward().Enable(sel.ID)
|
|
}
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
return true
|
|
case "d", "delete":
|
|
m.startPrompt("Remove Forward #"+fmt.Sprint(sel.ID), "type 'y' to confirm", "", func(v string) {
|
|
if strings.TrimSpace(strings.ToLower(v)) == "y" {
|
|
if err := m.App.Forward().Remove(sel.ID); err != nil {
|
|
m.panelError = err.Error()
|
|
} else {
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
}
|
|
})
|
|
return true
|
|
case "u":
|
|
m.startForwardForm("Update #"+fmt.Sprint(sel.ID), sel.Mode, sel.Address)
|
|
return true
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (m *Model) startForwardForm(title, mode, address string) {
|
|
modeIn := textinput.New()
|
|
modeIn.Prompt = " Type: "
|
|
modeIn.Placeholder = "Tab to cycle modes"
|
|
modeIn.SetValue(mode)
|
|
modeIn.CharLimit = 10
|
|
modeIn.Width = 36
|
|
|
|
addrIn := textinput.New()
|
|
addrIn.Prompt = " Addr: "
|
|
addrIn.Placeholder = "host:port or COM port"
|
|
addrIn.SetValue(address)
|
|
addrIn.CharLimit = 60
|
|
addrIn.Width = 36
|
|
|
|
m.formActive = true
|
|
m.formTitle = title
|
|
m.formLabels = []string{"Type (Tab cycle)", "Address"}
|
|
m.formFields = []textinput.Model{modeIn, addrIn}
|
|
m.formFocus = 0
|
|
m.formFields[0].Focus()
|
|
|
|
m.formSubmit = func(vals []string) {
|
|
modeStr := strings.TrimSpace(vals[0])
|
|
addrStr := strings.TrimSpace(vals[1])
|
|
|
|
fm, ok := forward.ParseMode(modeStr)
|
|
if !ok {
|
|
m.panelError = "unknown mode: " + modeStr
|
|
return
|
|
}
|
|
if addrStr == "" {
|
|
m.panelError = "address is required"
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(title, "Add") {
|
|
if _, err := m.App.Forward().Add(fm, addrStr); err != nil {
|
|
m.panelError = err.Error()
|
|
return
|
|
}
|
|
} else {
|
|
sel := m.forwardItems[m.panelIndex]
|
|
if err := m.App.Forward().Update(sel.ID, fm, addrStr); err != nil {
|
|
m.panelError = err.Error()
|
|
return
|
|
}
|
|
}
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
}
|
|
|
|
func (m *Model) handlePluginPanelKey(key string) bool {
|
|
switch key {
|
|
case "up", "k":
|
|
if m.panelIndex > 0 {
|
|
m.panelIndex--
|
|
}
|
|
return true
|
|
case "down", "j":
|
|
if m.panelIndex < len(m.pluginItems)-1 {
|
|
m.panelIndex++
|
|
}
|
|
return true
|
|
case "r":
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
return true
|
|
case "l":
|
|
m.startPrompt("Load Plugin", "./plugins/demo.lua", "", func(v string) {
|
|
path := strings.TrimSpace(v)
|
|
if path == "" {
|
|
m.panelError = "load path is empty"
|
|
return
|
|
}
|
|
if _, err := m.App.Plugins().Load(path); err != nil {
|
|
m.panelError = err.Error()
|
|
} else {
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
})
|
|
return true
|
|
}
|
|
if len(m.pluginItems) == 0 {
|
|
return true
|
|
}
|
|
|
|
sel := m.pluginItems[m.panelIndex]
|
|
switch key {
|
|
case "enter":
|
|
if sel.Enabled {
|
|
_ = m.App.Plugins().Disable(sel.Name)
|
|
} else {
|
|
_ = m.App.Plugins().Enable(sel.Name)
|
|
}
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
return true
|
|
case "u":
|
|
if err := m.App.Plugins().Reload(sel.Name); err != nil {
|
|
m.panelError = err.Error()
|
|
} else {
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
return true
|
|
case "d", "delete":
|
|
m.startPrompt("Unload Plugin "+sel.Name, "type 'y' to confirm", "", func(v string) {
|
|
if strings.TrimSpace(strings.ToLower(v)) == "y" {
|
|
if err := m.App.Plugins().Unload(sel.Name); err != nil {
|
|
m.panelError = err.Error()
|
|
} else {
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
}
|
|
}
|
|
})
|
|
return true
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (m *Model) handleModePanelKey(key string) bool {
|
|
switch key {
|
|
case "up", "k":
|
|
if m.panelIndex > 0 {
|
|
m.panelIndex--
|
|
}
|
|
return true
|
|
case "down", "j":
|
|
if m.panelIndex < len(m.modeItems)-1 {
|
|
m.panelIndex++
|
|
}
|
|
return true
|
|
case "r":
|
|
m.panelError = ""
|
|
m.refreshPanel()
|
|
return true
|
|
}
|
|
if len(m.modeItems) == 0 {
|
|
return true
|
|
}
|
|
|
|
sel := m.modeItems[m.panelIndex]
|
|
cfg := m.App.Cfg()
|
|
switch key {
|
|
case " ":
|
|
if sel.key == "timestamp" {
|
|
cfg.TimesTamp = !cfg.TimesTamp
|
|
m.refreshPanel()
|
|
}
|
|
return true
|
|
case "enter", "e":
|
|
hint := "enter value"
|
|
switch sel.key {
|
|
case "timestamp":
|
|
hint = "on/off"
|
|
case "frame":
|
|
hint = "positive integer"
|
|
case "in", "out":
|
|
hint = "charset name (e.g. utf-8, gbk)"
|
|
}
|
|
initial := sel.rawValue
|
|
m.startPrompt("Edit Mode: "+sel.label, hint, initial, func(v string) {
|
|
m.App.HandleLine(fmt.Sprintf(".mode set %s %s", sel.key, v))
|
|
m.refreshPanel()
|
|
})
|
|
return true
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (m *Model) startPrompt(title, hint, initial string, submit func(string)) {
|
|
in := textinput.New()
|
|
in.Prompt = "> "
|
|
in.Placeholder = hint
|
|
in.SetValue(initial)
|
|
in.Focus()
|
|
in.CharLimit = 0
|
|
in.Width = 64
|
|
|
|
m.promptActive = true
|
|
m.promptTitle = title
|
|
m.promptHint = hint
|
|
m.promptInput = in
|
|
m.promptSubmit = submit
|
|
}
|
|
|
|
// --- Form methods (multi-field input) ---
|
|
|
|
func (m *Model) handleFormKey(msg tea.KeyMsg) (bool, tea.Cmd) {
|
|
key := strings.ToLower(msg.String())
|
|
switch key {
|
|
case "esc":
|
|
m.formActive = false
|
|
m.formSubmit = nil
|
|
return true, nil
|
|
case "tab":
|
|
m.formFields[m.formFocus].Blur()
|
|
m.formFocus = (m.formFocus + 1) % len(m.formFields)
|
|
|
|
// Cycle forward mode on Tab when type field is focused
|
|
if m.formFocus == 0 {
|
|
cur := strings.TrimSpace(m.formFields[0].Value())
|
|
idx := -1
|
|
for i, m := range forwardModes {
|
|
if m == cur {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
idx = (idx + 1) % len(forwardModes)
|
|
m.formFields[0].SetValue(forwardModes[idx])
|
|
}
|
|
m.formFields[m.formFocus].Focus()
|
|
return true, nil
|
|
case "shift+tab":
|
|
m.formFields[m.formFocus].Blur()
|
|
m.formFocus = (m.formFocus - 1 + len(m.formFields)) % len(m.formFields)
|
|
if m.formFocus == 0 {
|
|
cur := strings.TrimSpace(m.formFields[0].Value())
|
|
idx := -1
|
|
for i, m := range forwardModes {
|
|
if m == cur { idx = i; break }
|
|
}
|
|
idx = (idx - 1 + len(forwardModes)) % len(forwardModes)
|
|
m.formFields[0].SetValue(forwardModes[idx])
|
|
}
|
|
m.formFields[m.formFocus].Focus()
|
|
return true, nil
|
|
case "enter":
|
|
vals := make([]string, len(m.formFields))
|
|
for i, f := range m.formFields {
|
|
vals[i] = f.Value()
|
|
}
|
|
submit := m.formSubmit
|
|
m.formActive = false
|
|
m.formSubmit = nil
|
|
if submit != nil {
|
|
submit(vals)
|
|
}
|
|
return true, nil
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.formFields[m.formFocus], cmd = m.formFields[m.formFocus].Update(msg)
|
|
return true, cmd
|
|
}
|
|
}
|
|
|
|
func (m *Model) renderForm() string {
|
|
lines := make([]boxLine, 0, len(m.formFields)+2)
|
|
for i, f := range m.formFields {
|
|
prefix := " "
|
|
if i == m.formFocus {
|
|
prefix = "▸ "
|
|
}
|
|
lines = append(lines, boxLine{
|
|
text: prefix + f.View(),
|
|
style: modalBodyLineStyle(),
|
|
})
|
|
}
|
|
footer := "Tab cycles Type | Enter submit | Esc cancel"
|
|
if len(m.formFields) > 1 {
|
|
footer = "Tab/Shift+Tab switch | Enter submit | Esc cancel"
|
|
}
|
|
lines = append(lines, boxLine{text: footer, style: modalFooterLineStyle()})
|
|
return renderBox(m.formTitle, lines, 36, m.availableModalWidth())
|
|
}
|
|
|
|
func (m *Model) handlePromptKey(msg tea.KeyMsg) (bool, tea.Cmd) {
|
|
key := strings.ToLower(msg.String())
|
|
switch key {
|
|
case "esc":
|
|
m.promptActive = false
|
|
m.promptSubmit = nil
|
|
return true, nil
|
|
case "enter":
|
|
value := strings.TrimSpace(m.promptInput.Value())
|
|
submit := m.promptSubmit
|
|
m.promptActive = false
|
|
m.promptSubmit = nil
|
|
if submit != nil {
|
|
submit(value)
|
|
}
|
|
return true, nil
|
|
default:
|
|
var cmd tea.Cmd
|
|
m.promptInput, cmd = m.promptInput.Update(msg)
|
|
return true, cmd
|
|
}
|
|
}
|
|
|
|
func (m *Model) renderPanel() string {
|
|
switch m.panelKind {
|
|
case event.UIPanelForward:
|
|
return m.renderForwardPanel()
|
|
case event.UIPanelPlugin:
|
|
return m.renderPluginPanel()
|
|
case event.UIPanelMode:
|
|
return m.renderModePanel()
|
|
default:
|
|
return renderModal("Info", "No panel", m.availableModalWidth())
|
|
}
|
|
}
|
|
|
|
func (m *Model) renderForwardPanel() string {
|
|
lines := make([]panelLine, 0, len(m.forwardItems)+3)
|
|
if len(m.forwardItems) == 0 {
|
|
lines = append(lines, panelLine{text: "No forwarding targets. Press 'a' to add one."})
|
|
} else {
|
|
lines = append(lines, panelLine{text: "ID Mode Enabled Connected Address"})
|
|
for i, it := range m.forwardItems {
|
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-3d %-5s %-7v %-9v %s", it.ID, it.Mode, it.Enabled, it.Connected, it.Address), selected: i == m.panelIndex})
|
|
}
|
|
}
|
|
if m.panelError != "" {
|
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
|
}
|
|
return renderPanelModal("Forward Panel", lines, "j/k select | Enter toggle | a add(form) | u update | d remove | r refresh | Esc close", m.availableModalWidth())
|
|
}
|
|
|
|
func (m *Model) renderPluginPanel() string {
|
|
lines := make([]panelLine, 0, len(m.pluginItems)+3)
|
|
if len(m.pluginItems) == 0 {
|
|
lines = append(lines, panelLine{text: "No plugins loaded. Press 'l' to load one."})
|
|
} else {
|
|
lines = append(lines, panelLine{text: "Name Enabled Path"})
|
|
for i, it := range m.pluginItems {
|
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-20s %-7v %s", it.Name, it.Enabled, it.Path), selected: i == m.panelIndex})
|
|
}
|
|
}
|
|
if m.panelError != "" {
|
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
|
}
|
|
return renderPanelModal("Plugin Panel", lines, "Up/Down select | Enter toggle | l load | u reload | d unload | r refresh | Esc close", m.availableModalWidth())
|
|
}
|
|
|
|
func (m *Model) renderModePanel() string {
|
|
lines := make([]panelLine, 0, len(m.modeItems)+3)
|
|
lines = append(lines, panelLine{text: "Field Value"})
|
|
for i, it := range m.modeItems {
|
|
lines = append(lines, panelLine{text: fmt.Sprintf("%-16s %s", it.label, it.value), selected: i == m.panelIndex})
|
|
}
|
|
if m.panelError != "" {
|
|
lines = append(lines, panelLine{text: "ERROR: " + m.panelError})
|
|
}
|
|
return renderPanelModal("Mode Panel", lines, "Up/Down select | Enter edit | Space toggle | r refresh | Esc close", m.availableModalWidth())
|
|
}
|
|
|