mirror of
https://github.com/jixishi/SerialTerminalForWindowsTerminal.git
synced 2026-06-16 00:52:44 +00:00
refactor: split termapp into proper internal packages
Replace monolithic internal/termapp with proper separation: - internal/app: App struct, lifecycle, output loops - internal/command: CommandHost interface, Dispatcher, handlers - internal/tui: Model, hotkeys, panels, render (with panelError + border fixes) - internal/console: RunConsole, escape parsing, entry point logic - cmd/serialterminal: thin main() calling console.Run() Eliminate global vars (cfg, sess, out) via dependency injection. Break App->CommandDispatcher cycle via CommandHost interface. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,153 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
|
||||
)
|
||||
|
||||
func handleLocalHotkey(m *Model, key string) bool {
|
||||
if m.isLocalHotkey(key, "h") {
|
||||
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))
|
||||
m.App.ShowModal("Shortcuts", modifier+"+C => local exit\nCtrl+C => remote interrupt\n"+modifier+"+F => forward panel\n"+modifier+"+P => plugin panel\n"+modifier+"+M => mode panel\nF1 => shortcut help")
|
||||
return true
|
||||
}
|
||||
if m.isLocalHotkey(key, "f") {
|
||||
m.App.OpenPanel(event.UIPanelForward)
|
||||
return true
|
||||
}
|
||||
if m.isLocalHotkey(key, "p") {
|
||||
m.App.OpenPanel(event.UIPanelPlugin)
|
||||
return true
|
||||
}
|
||||
if m.isLocalHotkey(key, "m") {
|
||||
m.App.OpenPanel(event.UIPanelMode)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) isLocalHotkey(key, action string) bool {
|
||||
parts := strings.Split(strings.ToLower(key), "+")
|
||||
if len(parts) < 2 || parts[len(parts)-1] != action {
|
||||
return false
|
||||
}
|
||||
|
||||
hasCtrl := false
|
||||
hasAlt := false
|
||||
hasShift := false
|
||||
for _, p := range parts[:len(parts)-1] {
|
||||
switch p {
|
||||
case "ctrl":
|
||||
hasCtrl = true
|
||||
case "alt":
|
||||
hasAlt = true
|
||||
case "shift":
|
||||
hasShift = true
|
||||
}
|
||||
}
|
||||
|
||||
mod := normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod)
|
||||
if mod == "ctrl+shift" {
|
||||
return hasCtrl && hasShift
|
||||
}
|
||||
return hasCtrl && hasAlt
|
||||
}
|
||||
|
||||
func normalizeHotkeyPrefix(mod string) string {
|
||||
mod = strings.ToLower(strings.TrimSpace(mod))
|
||||
if mod != "ctrl+alt" && mod != "ctrl+shift" {
|
||||
mod = "ctrl+alt"
|
||||
}
|
||||
return mod
|
||||
}
|
||||
|
||||
func hotkeyWith(mod, action string) string {
|
||||
return normalizeHotkeyPrefix(mod) + "+" + action
|
||||
}
|
||||
|
||||
func parseCtrlKey(key string) (byte, bool) {
|
||||
if !strings.HasPrefix(key, "ctrl+") || strings.HasPrefix(key, "ctrl+shift+") {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
parts := strings.Split(key, "+")
|
||||
if len(parts) != 2 || len(parts[1]) != 1 {
|
||||
return 0, false
|
||||
}
|
||||
ch := parts[1][0]
|
||||
if ch < 'a' || ch > 'z' {
|
||||
return 0, false
|
||||
}
|
||||
return ch, true
|
||||
}
|
||||
|
||||
func (m *Model) handleViewportKey(msg tea.KeyMsg) bool {
|
||||
if !m.ready || m.showModal {
|
||||
return false
|
||||
}
|
||||
|
||||
key := strings.ToLower(msg.String())
|
||||
switch key {
|
||||
case "pgup", "ctrl+u", "alt+up", "up":
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
_ = cmd
|
||||
m.followTail = false
|
||||
return true
|
||||
case "pgdown", "ctrl+d", "alt+down", "down":
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
_ = cmd
|
||||
return true
|
||||
case "home":
|
||||
m.viewport.GotoTop()
|
||||
m.followTail = false
|
||||
return true
|
||||
case "end":
|
||||
m.viewport.GotoBottom()
|
||||
m.followTail = true
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) resetCompletion() {
|
||||
m.completionActive = false
|
||||
m.completionBase = ""
|
||||
m.completionCandidates = nil
|
||||
m.completionIndex = 0
|
||||
}
|
||||
|
||||
func (m *Model) stepCompletion(direction int) {
|
||||
if len(m.completionCandidates) == 0 {
|
||||
m.resetCompletion()
|
||||
return
|
||||
}
|
||||
if direction >= 0 {
|
||||
m.completionIndex = (m.completionIndex + 1) % len(m.completionCandidates)
|
||||
} else {
|
||||
m.completionIndex = (m.completionIndex - 1 + len(m.completionCandidates)) % len(m.completionCandidates)
|
||||
}
|
||||
m.applyCompletion()
|
||||
}
|
||||
|
||||
func (m *Model) applyCompletion() {
|
||||
if len(m.completionCandidates) == 0 {
|
||||
return
|
||||
}
|
||||
m.input.SetValue(m.completionBase + m.completionCandidates[m.completionIndex] + " ")
|
||||
}
|
||||
|
||||
func completionBase(line string) string {
|
||||
if strings.HasSuffix(line, " ") {
|
||||
return line
|
||||
}
|
||||
i := strings.LastIndex(line, " ")
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
return line[:i+1]
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package tui
|
||||
|
||||
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/app"
|
||||
"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
|
||||
rawValue string
|
||||
}
|
||||
|
||||
type panelLine struct {
|
||||
text string
|
||||
selected bool
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
App *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
|
||||
panelError string
|
||||
|
||||
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 New(application *app.App) *Model {
|
||||
in := textinput.New()
|
||||
in.Placeholder = "Type to send to remote, use .help for commands"
|
||||
in.Focus()
|
||||
in.CharLimit = 0
|
||||
in.Prompt = "> "
|
||||
in.Width = 80
|
||||
|
||||
return &Model{App: application, input: in, followTail: true}
|
||||
}
|
||||
|
||||
func (m *Model) 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 *Model) 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 {
|
||||
handled, cmd := m.handleModalKey(msg)
|
||||
if handled {
|
||||
return m, cmd
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 *Model) 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)
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
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.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.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},
|
||||
}
|
||||
}
|
||||
|
||||
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.startPrompt("Add Forward", "tcp 127.0.0.1:12345", "", func(v string) {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) < 2 {
|
||||
m.panelError = "usage: <tcp|udp> <address>"
|
||||
return
|
||||
}
|
||||
mode, ok := forward.ParseMode(parts[0])
|
||||
if !ok {
|
||||
m.panelError = "unknown mode: " + parts[0]
|
||||
return
|
||||
}
|
||||
if _, err := m.App.Forward().Add(mode, parts[1]); err != nil {
|
||||
m.panelError = err.Error()
|
||||
} else {
|
||||
m.panelError = ""
|
||||
m.refreshPanel()
|
||||
}
|
||||
})
|
||||
return true
|
||||
}
|
||||
if len(m.forwardItems) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
sel := m.forwardItems[m.panelIndex]
|
||||
switch key {
|
||||
case "enter":
|
||||
if sel.Enabled {
|
||||
if err := m.App.Forward().Disable(sel.ID); err != nil {
|
||||
m.panelError = err.Error()
|
||||
}
|
||||
} else {
|
||||
if err := m.App.Forward().Enable(sel.ID); err != nil {
|
||||
m.panelError = err.Error()
|
||||
}
|
||||
}
|
||||
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.startPrompt("Update Forward #"+fmt.Sprint(sel.ID), "tcp 127.0.0.1:12345", fmt.Sprintf("%s %s", sel.Mode, sel.Address), func(v string) {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) < 2 {
|
||||
m.panelError = "usage: <tcp|udp> <address>"
|
||||
return
|
||||
}
|
||||
mode, ok := forward.ParseMode(parts[0])
|
||||
if !ok {
|
||||
m.panelError = "unknown mode: " + parts[0]
|
||||
return
|
||||
}
|
||||
if err := m.App.Forward().Update(sel.ID, mode, parts[1]); err != nil {
|
||||
m.panelError = err.Error()
|
||||
} else {
|
||||
m.panelError = ""
|
||||
m.refreshPanel()
|
||||
}
|
||||
})
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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, "Up/Down select | Enter toggle | a add | 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())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
func (m *Model) appendOutput(text string) {
|
||||
if text == "" {
|
||||
return
|
||||
}
|
||||
m.content.WriteString(text)
|
||||
if m.ready {
|
||||
m.viewport.SetContent(m.content.String())
|
||||
if m.followTail {
|
||||
m.viewport.GotoBottom()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) renderPrompt() string {
|
||||
lines := []boxLine{
|
||||
{text: m.promptHint, style: modalBodyLineStyle()},
|
||||
{text: m.promptInput.View(), style: modalBodyLineStyle()},
|
||||
{text: "Enter submit | Esc cancel", style: modalFooterLineStyle()},
|
||||
}
|
||||
return renderBox(m.promptTitle, lines, 48, m.availableModalWidth())
|
||||
}
|
||||
|
||||
func renderModal(title, body string, maxWidth int) string {
|
||||
if title == "" {
|
||||
title = "Info"
|
||||
}
|
||||
parts := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
|
||||
if len(parts) > 12 {
|
||||
parts = append(parts[:12], "... (press Esc/Enter to close)")
|
||||
}
|
||||
lines := make([]boxLine, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
lines = append(lines, boxLine{text: part, style: modalBodyLineStyle()})
|
||||
}
|
||||
return renderBox(title, lines, 20, maxWidth)
|
||||
}
|
||||
|
||||
func renderPanelModal(title string, lines []panelLine, footer string, maxWidth int) string {
|
||||
boxLines := make([]boxLine, 0, len(lines)+1)
|
||||
for _, line := range lines {
|
||||
style := modalBodyLineStyle()
|
||||
prefix := " "
|
||||
if line.selected {
|
||||
style = selectedPanelLineStyle()
|
||||
prefix = "▸ "
|
||||
}
|
||||
boxLines = append(boxLines, boxLine{text: prefix + line.text, style: style})
|
||||
}
|
||||
boxLines = append(boxLines, boxLine{text: footer, style: modalFooterLineStyle()})
|
||||
return renderBox(title, boxLines, 40, maxWidth)
|
||||
}
|
||||
|
||||
func fillScreen(width, height int, content string) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return content
|
||||
}
|
||||
return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
||||
)
|
||||
}
|
||||
|
||||
func renderCenteredModal(width, height int, title, body string) string {
|
||||
maxWidth := width - 8
|
||||
if maxWidth < 20 {
|
||||
maxWidth = 20
|
||||
}
|
||||
return renderCenteredModalContent(width, height, renderModal(title, body, maxWidth))
|
||||
}
|
||||
|
||||
func renderCenteredModalContent(width, height int, content string) string {
|
||||
if width <= 0 || height <= 0 {
|
||||
return content
|
||||
}
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content,
|
||||
lipgloss.WithWhitespaceChars(" "),
|
||||
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
|
||||
)
|
||||
}
|
||||
|
||||
func (m *Model) availableModalWidth() int {
|
||||
if m.width <= 0 {
|
||||
return 100
|
||||
}
|
||||
maxWidth := m.width - 8
|
||||
if maxWidth < 20 {
|
||||
maxWidth = 20
|
||||
}
|
||||
return maxWidth
|
||||
}
|
||||
|
||||
type boxLine struct {
|
||||
text string
|
||||
style lipgloss.Style
|
||||
}
|
||||
|
||||
func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string {
|
||||
contentWidth := lipgloss.Width(title)
|
||||
for _, line := range lines {
|
||||
contentWidth = maxInt(contentWidth, lipgloss.Width(line.text))
|
||||
}
|
||||
contentWidth = maxInt(minWidth, contentWidth)
|
||||
contentWidth = minInt(contentWidth, maxWidth)
|
||||
|
||||
boxStyle := lipgloss.NewStyle().Background(lipgloss.Color("236"))
|
||||
top := boxStyle.Render("╭" + strings.Repeat("─", contentWidth+2) + "╮")
|
||||
bottom := boxStyle.Render("╰" + strings.Repeat("─", contentWidth+2) + "╯")
|
||||
|
||||
rows := make([]string, 0, len(lines)+3)
|
||||
rows = append(rows, top)
|
||||
rows = append(rows, renderBoxRow(modalHeaderLineStyle(), title, contentWidth))
|
||||
for _, line := range lines {
|
||||
rows = append(rows, renderBoxRow(line.style, truncateToWidth(line.text, contentWidth), contentWidth))
|
||||
}
|
||||
rows = append(rows, bottom)
|
||||
return strings.Join(rows, "\n")
|
||||
}
|
||||
|
||||
func renderBoxRow(contentStyle lipgloss.Style, text string, width int) string {
|
||||
visible := truncateToWidth(text, width)
|
||||
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(visible)))
|
||||
inner := contentStyle.Render(" " + visible + pad + " ")
|
||||
return contentStyle.Render("│" + inner + "│")
|
||||
}
|
||||
|
||||
func modalHeaderLineStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
|
||||
}
|
||||
|
||||
func modalBodyLineStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236"))
|
||||
}
|
||||
|
||||
func modalFooterLineStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236"))
|
||||
}
|
||||
|
||||
func selectedPanelLineStyle() lipgloss.Style {
|
||||
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("31"))
|
||||
}
|
||||
|
||||
func truncateToWidth(s string, width int) string {
|
||||
if width <= 0 || lipgloss.Width(s) <= width {
|
||||
return s
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
next := b.String() + string(r)
|
||||
if lipgloss.Width(next) > width {
|
||||
break
|
||||
}
|
||||
b.WriteRune(r)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func clampIndex(idx, n int) int {
|
||||
if n <= 0 || idx < 0 {
|
||||
return 0
|
||||
}
|
||||
if idx >= n {
|
||||
return n - 1
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func maxInt(a int, rest ...int) int {
|
||||
max := a
|
||||
for _, v := range rest {
|
||||
if v > max {
|
||||
max = v
|
||||
}
|
||||
}
|
||||
return max
|
||||
}
|
||||
Reference in New Issue
Block a user