mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-15 16:42:46 +00:00
2ce672cdde
Move ForwardManager → pkg/forward/Manager and PluginManager → pkg/luaplugin/Manager. Move FoeWardMode (now forward.Mode) with ParseMode/Network/String into pkg/forward. Rename constants: NOT→None, TCPC→TCP, UDPC→UDP. Update all references in main package. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
271 lines
6.2 KiB
Go
271 lines
6.2 KiB
Go
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
"github.com/charmbracelet/bubbles/viewport"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
|
|
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
|
|
)
|
|
|
|
type doneMsg struct{}
|
|
|
|
type modeItem struct {
|
|
key string
|
|
label string
|
|
value string
|
|
}
|
|
|
|
type panelLine struct {
|
|
text string
|
|
selected bool
|
|
}
|
|
|
|
type uiModel struct {
|
|
app *App
|
|
|
|
viewport viewport.Model
|
|
input textinput.Model
|
|
|
|
ready bool
|
|
width int
|
|
height int
|
|
statusLine string
|
|
suggestions []string
|
|
content strings.Builder
|
|
followTail bool
|
|
|
|
showModal bool
|
|
modalTitle string
|
|
modalBody string
|
|
|
|
panelKind event.UIPanelKind
|
|
panelIndex int
|
|
|
|
forwardItems []forward.Snapshot
|
|
pluginItems []luaplugin.Snapshot
|
|
modeItems []modeItem
|
|
|
|
promptActive bool
|
|
promptTitle string
|
|
promptHint string
|
|
promptInput textinput.Model
|
|
promptSubmit func(string)
|
|
|
|
completionActive bool
|
|
completionBase string
|
|
completionCandidates []string
|
|
completionIndex int
|
|
}
|
|
|
|
func newUIModel(app *App) *uiModel {
|
|
in := textinput.New()
|
|
// bubbles v0.18.0 computes placeholder width using display cells,
|
|
// which can panic on CJK placeholders. Keep this ASCII-only.
|
|
in.Placeholder = "Type to send to remote, use .help for commands"
|
|
in.Focus()
|
|
in.CharLimit = 0
|
|
in.Prompt = "> "
|
|
in.Width = 80
|
|
|
|
return &uiModel{app: app, input: in, followTail: true}
|
|
}
|
|
|
|
func (m *uiModel) Init() tea.Cmd {
|
|
return tea.Batch(waitUIEvent(m.app.uiEvents), waitDone(m.app.waitDone()), textinput.Blink)
|
|
}
|
|
|
|
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
|
|
return func() tea.Msg {
|
|
ev, ok := <-ch
|
|
if !ok {
|
|
return doneMsg{}
|
|
}
|
|
return ev
|
|
}
|
|
}
|
|
|
|
func waitDone(ch <-chan struct{}) tea.Cmd {
|
|
return func() tea.Msg {
|
|
<-ch
|
|
return doneMsg{}
|
|
}
|
|
}
|
|
|
|
func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case doneMsg:
|
|
return m, tea.Quit
|
|
|
|
case event.UIEvent:
|
|
switch msg.Kind {
|
|
case event.UIEventOutput, event.UIEventStatus:
|
|
if msg.Kind == event.UIEventOutput {
|
|
m.appendOutput(msg.Text)
|
|
} else {
|
|
m.statusLine = msg.Text
|
|
}
|
|
case event.UIEventModal:
|
|
m.showModal = true
|
|
m.panelKind = event.UIPanelNone
|
|
m.modalTitle = msg.Title
|
|
m.modalBody = msg.Text
|
|
m.promptActive = false
|
|
case event.UIEventPanel:
|
|
m.openPanel(msg.Panel)
|
|
}
|
|
return m, waitUIEvent(m.app.uiEvents)
|
|
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
inputHeight := 3
|
|
statusHeight := 2
|
|
viewportHeight := msg.Height - inputHeight - statusHeight
|
|
if viewportHeight < 3 {
|
|
viewportHeight = 3
|
|
}
|
|
|
|
if !m.ready {
|
|
m.viewport = viewport.New(msg.Width, viewportHeight)
|
|
m.viewport.YPosition = 0
|
|
m.viewport.SetContent(m.content.String())
|
|
m.ready = true
|
|
} else {
|
|
m.viewport.Width = msg.Width
|
|
m.viewport.Height = viewportHeight
|
|
}
|
|
|
|
m.input.Width = msg.Width - 4
|
|
m.viewport.GotoBottom()
|
|
m.followTail = true
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
keyStr := strings.ToLower(msg.String())
|
|
if m.handleViewportKey(msg) {
|
|
return m, nil
|
|
}
|
|
if keyStr != "tab" && keyStr != "shift+tab" {
|
|
m.resetCompletion()
|
|
}
|
|
|
|
if m.showModal && m.handleModalKey(msg) {
|
|
return m, nil
|
|
}
|
|
|
|
if m.isLocalHotkey(keyStr, "c") {
|
|
m.app.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.hotkeyMod)))
|
|
m.app.Close()
|
|
return m, tea.Quit
|
|
}
|
|
|
|
if handleLocalHotkey(m, keyStr) {
|
|
return m, nil
|
|
}
|
|
|
|
// Some terminals can't encode Ctrl+Alt/Shift+H distinctly and report Ctrl+H.
|
|
if keyStr == "ctrl+h" {
|
|
handleLocalHotkey(m, hotkeyWith(m.app.cfg.hotkeyMod, "h"))
|
|
return m, nil
|
|
}
|
|
|
|
if letter, ok := parseCtrlKey(keyStr); ok {
|
|
if err := m.app.sendCtrl(letter); err != nil {
|
|
m.app.Notifyf("[remote] ctrl send failed: %v", err)
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
switch keyStr {
|
|
case "f1":
|
|
handleLocalHotkey(m, hotkeyWith(m.app.cfg.hotkeyMod, "h"))
|
|
return m, nil
|
|
|
|
case "tab", "shift+tab":
|
|
direction := 1
|
|
if keyStr == "shift+tab" {
|
|
direction = -1
|
|
}
|
|
|
|
if m.completionActive && len(m.completionCandidates) > 0 {
|
|
m.stepCompletion(direction)
|
|
return m, nil
|
|
}
|
|
|
|
line, cands := m.app.dispatcher.Complete(m.input.Value())
|
|
m.suggestions = cands
|
|
if len(cands) == 0 {
|
|
return m, nil
|
|
}
|
|
if len(cands) == 1 {
|
|
m.input.SetValue(line)
|
|
return m, nil
|
|
}
|
|
|
|
m.completionActive = true
|
|
m.completionBase = completionBase(m.input.Value())
|
|
m.completionCandidates = append([]string(nil), cands...)
|
|
if direction < 0 {
|
|
m.completionIndex = len(cands) - 1
|
|
} else {
|
|
m.completionIndex = 0
|
|
}
|
|
m.applyCompletion()
|
|
return m, nil
|
|
|
|
case "enter":
|
|
line := m.input.Value()
|
|
m.input.SetValue("")
|
|
m.suggestions = nil
|
|
m.followTail = true
|
|
m.app.handleLine(line)
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
var cmd tea.Cmd
|
|
m.input, cmd = m.input.Update(msg)
|
|
return m, cmd
|
|
}
|
|
|
|
func (m *uiModel) View() string {
|
|
if !m.ready {
|
|
return "Initializing..."
|
|
}
|
|
|
|
suggest := "Tab: no candidates"
|
|
if len(m.suggestions) > 1 {
|
|
suggest = "Tab candidates: " + strings.Join(m.suggestions, " ")
|
|
} else if len(m.suggestions) == 1 {
|
|
suggest = "Tab: " + m.suggestions[0]
|
|
}
|
|
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.hotkeyMod))
|
|
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
|
|
hotkeys = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("245")).Render(hotkeys)
|
|
status := m.statusLine
|
|
if status == "" {
|
|
status = "Ready"
|
|
}
|
|
status = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Faint(true).Render(status)
|
|
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.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)
|
|
}
|