From d8fc9d7374c17e96a2b9b3849fd7fdd32f1280f3 Mon Sep 17 00:00:00 2001 From: JiXieShi Date: Sat, 23 May 2026 22:46:02 +0800 Subject: [PATCH] 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 --- cmd/serialterminal/main.go | 12 +- internal/{termapp => app}/app.go | 78 +-- internal/command/commands.go | 227 ++++++++ internal/command/complete.go | 67 +++ internal/command/dispatcher.go | 217 ++++++++ .../{termapp/main.go => console/console.go} | 183 +++---- .../console_other.go} | 2 +- internal/console/console_windows.go | 14 + internal/flag/flag.go | 4 + internal/termapp/app_test.go | 259 --------- internal/termapp/command.go | 487 ----------------- internal/termapp/command_test.go | 502 ------------------ internal/termapp/config.go | 10 - internal/termapp/config_test.go | 82 --- internal/termapp/escape_test.go | 123 ----- internal/termapp/main_windows.go | 14 - internal/termapp/mutual.go | 13 - internal/termapp/tui_test.go | 309 ----------- .../tui_hotkeys.go => tui/hotkeys.go} | 26 +- .../{termapp/tui_model.go => tui/model.go} | 51 +- .../{termapp/tui_panels.go => tui/panels.go} | 209 +++++--- .../{termapp/tui_render.go => tui/render.go} | 50 +- 22 files changed, 859 insertions(+), 2080 deletions(-) rename internal/{termapp => app}/app.go (78%) create mode 100644 internal/command/commands.go create mode 100644 internal/command/complete.go create mode 100644 internal/command/dispatcher.go rename internal/{termapp/main.go => console/console.go} (55%) rename internal/{termapp/main_other.go => console/console_other.go} (76%) create mode 100644 internal/console/console_windows.go delete mode 100644 internal/termapp/app_test.go delete mode 100644 internal/termapp/command.go delete mode 100644 internal/termapp/command_test.go delete mode 100644 internal/termapp/config.go delete mode 100644 internal/termapp/config_test.go delete mode 100644 internal/termapp/escape_test.go delete mode 100644 internal/termapp/main_windows.go delete mode 100644 internal/termapp/mutual.go delete mode 100644 internal/termapp/tui_test.go rename internal/{termapp/tui_hotkeys.go => tui/hotkeys.go} (82%) rename internal/{termapp/tui_model.go => tui/model.go} (81%) rename internal/{termapp/tui_panels.go => tui/panels.go} (52%) rename internal/{termapp/tui_render.go => tui/render.go} (82%) diff --git a/cmd/serialterminal/main.go b/cmd/serialterminal/main.go index caae043..2ce1a83 100644 --- a/cmd/serialterminal/main.go +++ b/cmd/serialterminal/main.go @@ -1,7 +1,15 @@ package main -import "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/termapp" +import ( + "log" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/console" +) + +func init() { + log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile | log.Lmsgprefix) +} func main() { - termapp.Run() + console.Run() } diff --git a/internal/termapp/app.go b/internal/app/app.go similarity index 78% rename from internal/termapp/app.go rename to internal/app/app.go index 5241732..14bc081 100644 --- a/internal/termapp/app.go +++ b/internal/app/app.go @@ -1,4 +1,5 @@ -package termapp +// Package app provides the core application coordinator. +package app import ( "bytes" @@ -13,16 +14,23 @@ import ( appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/charset" "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/command" ) +// App is the central coordinator for the serial terminal application. type App struct { - cfg *Config + cfg *appconfig.Config + sess *session.SerialSession + out io.Writer + forward *forward.Manager plugins *luaplugin.Manager - dispatcher *CommandDispatcher + dispatcher *command.Dispatcher uiEvents chan event.UIEvent done chan struct{} @@ -35,7 +43,10 @@ type App struct { logFile *os.File } -func NewApp(cfg *Config) (*App, error) { +var _ command.CommandHost = (*App)(nil) + +// New creates a new App with the given configuration, session, and output writer. +func New(cfg *appconfig.Config, sess *session.SerialSession, out io.Writer) (*App, error) { f, err := appconfig.OpenLogFile(cfg) if err != nil { return nil, err @@ -43,6 +54,8 @@ func NewApp(cfg *Config) (*App, error) { a := &App{ cfg: cfg, + sess: sess, + out: out, plugins: luaplugin.NewManager(), uiEvents: make(chan event.UIEvent, 512), done: make(chan struct{}), @@ -52,13 +65,32 @@ func NewApp(cfg *Config) (*App, error) { a.forward = forward.NewManager(a.writeRawToSession, a.Notifyf) a.forward.SetInboundReporter(a.reportForwardIngress) - a.dispatcher = NewCommandDispatcher(a) + a.dispatcher = command.NewDispatcher(a) if err = a.loadDefaultDemoPlugin(); err != nil { return nil, err } return a, nil } +// --- command.CommandHost implementation --- + +func (a *App) Cfg() *appconfig.Config { return a.cfg } +func (a *App) Forward() *forward.Manager { return a.forward } +func (a *App) Plugins() *luaplugin.Manager { return a.plugins } +func (a *App) WriteToSession(data []byte) error { return a.writeToSession(data) } + +// --- exported accessors for TUI / console --- + +func (a *App) UIEvents() <-chan event.UIEvent { return a.uiEvents } +func (a *App) WaitDone() <-chan struct{} { return a.done } +func (a *App) SendCtrl(letter byte) error { return a.sendCtrl(letter) } +func (a *App) HandleLine(line string) { a.handleLine(line) } +func (a *App) Dispatcher() *command.Dispatcher { return a.dispatcher } +func (a *App) StartOutputLoop() { a.startOutputLoop() } +func (a *App) LoadConfiguredForwards() { a.loadConfiguredForwards() } +func (a *App) Sess() *session.SerialSession { return a.sess } +func (a *App) Out() io.Writer { return a.out } + func (a *App) loadDefaultDemoPlugin() error { demoPath := filepath.Join("plugins", "demo.lua") if _, err := os.Stat(demoPath); err != nil { @@ -107,14 +139,14 @@ func (a *App) emit(ev event.UIEvent) { if !a.UIEnabled() { switch ev.Kind { case event.UIEventOutput: - _, _ = io.WriteString(out, ev.Text) + _, _ = io.WriteString(a.out, ev.Text) case event.UIEventStatus: - _, _ = io.WriteString(out, ev.Text) + _, _ = io.WriteString(a.out, ev.Text) if !strings.HasSuffix(ev.Text, "\n") { - _, _ = io.WriteString(out, "\n") + _, _ = io.WriteString(a.out, "\n") } case event.UIEventModal: - _, _ = io.WriteString(out, "\n["+ev.Title+"]\n"+ev.Text+"\n") + _, _ = io.WriteString(a.out, "\n["+ev.Title+"]\n"+ev.Text+"\n") } if ev.Kind == event.UIEventOutput { a.appendLog(ev.Text) @@ -125,7 +157,6 @@ func (a *App) emit(ev event.UIEvent) { select { case a.uiEvents <- ev: default: - // Keep UI responsive; drop oldest when overloaded. select { case <-a.uiEvents: default: @@ -142,32 +173,22 @@ func (a *App) appendLog(text string) { if a.logFile == nil { return } - _, _ = a.logFile.WriteString(text) } -func (a *App) isClosed() bool { - return a.closedFlag.Load() -} - func (a *App) Close() { a.closeOnce.Do(func() { a.closedFlag.Store(true) close(a.done) a.forward.Close() a.plugins.Close() - sess.Close() - + a.sess.Close() if a.logFile != nil { _ = a.logFile.Close() } }) } -func (a *App) waitDone() <-chan struct{} { - return a.done -} - func (a *App) loadConfiguredForwards() { for i, mode := range a.cfg.ForWard { m := forward.Mode(mode) @@ -192,12 +213,10 @@ func (a *App) reportForwardIngress(id int, chunk []byte) { if len(chunk) == 0 { return } - if strings.EqualFold(a.cfg.InputCode, "hex") { a.Notifyf("[forward#%d -> serial] % X\n", id, chunk) return } - converted, err := charset.ConvertChunk(chunk, a.cfg.InputCode, a.cfg.OutputCode) if err != nil { converted = bytes.Clone(chunk) @@ -213,10 +232,9 @@ func (a *App) writeRawToSession(data []byte) error { if len(data) == 0 { return nil } - a.stdinMu.Lock() defer a.stdinMu.Unlock() - _, err := sess.StdinPipe.Write(data) + _, err := a.sess.StdinPipe.Write(data) return err } @@ -228,7 +246,6 @@ func (a *App) writeToSession(data []byte) error { if len(processed) == 0 { return nil } - return a.writeRawToSession(processed) } @@ -236,7 +253,6 @@ func (a *App) sendLine(line string) error { if strings.TrimSpace(line) == "" { return nil } - payload := append([]byte(line), []byte(a.cfg.EndStr)...) return a.writeToSession(payload) } @@ -246,7 +262,7 @@ func (a *App) sendCtrl(letter byte) error { letter = letter + ('a' - 'A') } control := []byte{letter & 0x1f} - _, err := sess.Port.Write(control) + _, err := a.sess.Port.Write(control) return err } @@ -288,7 +304,6 @@ func (a *App) startOutputLoop() { go a.readHexOutput() return } - go a.readTextOutput() } @@ -300,7 +315,7 @@ func (a *App) readHexOutput() { buf := make([]byte, frameSize) for { - n, err := sess.StdoutPipe.Read(buf) + n, err := a.sess.StdoutPipe.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) @@ -333,7 +348,7 @@ func (a *App) readHexOutput() { func (a *App) readTextOutput() { buf := make([]byte, 4096) for { - n, err := sess.StdoutPipe.Read(buf) + n, err := a.sess.StdoutPipe.Read(buf) if n > 0 { chunk := make([]byte, n) copy(chunk, buf[:n]) @@ -379,7 +394,6 @@ func prefixLines(s, prefix string) string { if s == "" || prefix == "" { return s } - lines := strings.SplitAfter(s, "\n") for i, line := range lines { if line == "" { diff --git a/internal/command/commands.go b/internal/command/commands.go new file mode 100644 index 0000000..4af7618 --- /dev/null +++ b/internal/command/commands.go @@ -0,0 +1,227 @@ +package command + +import ( + "fmt" + "strconv" + "strings" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" +) + +func (d *Dispatcher) handleForwardCommand(args []string) error { + if len(args) < 2 { + if d.host.UIEnabled() { + d.host.OpenPanel(event.UIPanelForward) + return nil + } + args = []string{".forward", "list"} + } + + sub := strings.ToLower(args[1]) + switch sub { + case "list", "stats": + if d.host.UIEnabled() { + d.host.OpenPanel(event.UIPanelForward) + return nil + } + + items := d.host.Forward().List() + if len(items) == 0 { + d.host.Notifyf("[forward] empty") + return nil + } + d.host.Notifyf("[forward] ID Mode Enabled Connected Address InBytes OutBytes LastError") + for _, it := range items { + d.host.Notifyf("[forward] %d %s %v %v %s %d %d %s", it.ID, it.Mode, it.Enabled, it.Connected, it.Address, it.ReadBytes, it.WriteByte, it.LastError) + } + return nil + + case "add": + if len(args) < 4 { + return fmt.Errorf("usage: .forward add
") + } + mode, ok := forward.ParseMode(args[2]) + if !ok { + return fmt.Errorf("unknown forward mode: %s", args[2]) + } + id, err := d.host.Forward().Add(mode, args[3]) + if err != nil { + return err + } + d.host.Statusf("[forward] added #%d", id) + return nil + + case "remove", "enable", "disable": + if len(args) < 3 { + return fmt.Errorf("usage: .forward %s ", sub) + } + id, err := strconv.Atoi(args[2]) + if err != nil { + return err + } + switch sub { + case "remove": + return d.host.Forward().Remove(id) + case "enable": + return d.host.Forward().Enable(id) + case "disable": + return d.host.Forward().Disable(id) + } + + case "update": + if len(args) < 5 { + return fmt.Errorf("usage: .forward update
") + } + id, err := strconv.Atoi(args[2]) + if err != nil { + return err + } + mode, ok := forward.ParseMode(args[3]) + if !ok { + return fmt.Errorf("unknown forward mode: %s", args[3]) + } + if err = d.host.Forward().Update(id, mode, args[4]); err != nil { + return err + } + d.host.Statusf("[forward] updated #%d", id) + return nil + } + + return fmt.Errorf("unknown subcommand: %s", sub) +} + +func (d *Dispatcher) handlePluginCommand(args []string) error { + if len(args) < 2 { + if d.host.UIEnabled() { + d.host.OpenPanel(event.UIPanelPlugin) + return nil + } + args = []string{".plugin", "list"} + } + + sub := strings.ToLower(args[1]) + switch sub { + case "list": + if d.host.UIEnabled() { + d.host.OpenPanel(event.UIPanelPlugin) + return nil + } + + items := d.host.Plugins().List() + if len(items) == 0 { + d.host.Notifyf("[plugin] empty") + return nil + } + for _, it := range items { + d.host.Notifyf("[plugin] %s enabled=%v path=%s", it.Name, it.Enabled, it.Path) + } + return nil + + case "load": + if len(args) < 3 { + return fmt.Errorf("usage: .plugin load ") + } + name, err := d.host.Plugins().Load(args[2]) + if err != nil { + return err + } + d.host.Statusf("[plugin] loaded %s", name) + return nil + + case "unload", "enable", "disable", "reload": + if len(args) < 3 { + return fmt.Errorf("usage: .plugin %s ", sub) + } + name := args[2] + switch sub { + case "unload": + return d.host.Plugins().Unload(name) + case "enable": + return d.host.Plugins().Enable(name) + case "disable": + return d.host.Plugins().Disable(name) + case "reload": + return d.host.Plugins().Reload(name) + } + } + + return fmt.Errorf("unknown subcommand: %s", sub) +} + +func (d *Dispatcher) handleModeCommand(args []string) error { + if len(args) < 2 || strings.EqualFold(args[1], "show") { + if d.host.UIEnabled() { + d.host.OpenPanel(event.UIPanelMode) + return nil + } + + cfg := d.host.Cfg() + d.host.Notifyf("[mode] input=%s output=%s end=%q hex=%v frame=%d timestamp=%v timefmt=%q forwardTargets=%d plugins=%d", + cfg.InputCode, cfg.OutputCode, cfg.EndStr, + strings.EqualFold(cfg.InputCode, "hex"), + cfg.FrameSize, cfg.TimesTamp, cfg.TimesFmt, + len(d.host.Forward().List()), len(d.host.Plugins().List()), + ) + return nil + } + + if !strings.EqualFold(args[1], "set") { + return fmt.Errorf("usage: .mode ") + } + if len(args) < 4 { + return fmt.Errorf("usage: .mode set ") + } + + field := strings.ToLower(args[2]) + value := strings.Join(args[3:], " ") + + cfg := d.host.Cfg() + switch field { + case "in": + if value == "" { + return fmt.Errorf("input charset must not be empty") + } + cfg.InputCode = value + case "out": + if value == "" { + return fmt.Errorf("output charset must not be empty") + } + cfg.OutputCode = value + case "end": + cfg.EndStr = value + case "frame": + n, err := strconv.Atoi(value) + if err != nil || n <= 0 { + return fmt.Errorf("frame must be a positive integer") + } + cfg.FrameSize = n + case "timestamp": + enabled, ok := parseOnOff(value) + if !ok { + return fmt.Errorf("timestamp value must be on/off") + } + cfg.TimesTamp = enabled + case "timefmt": + if value == "" && cfg.TimesTamp { + return fmt.Errorf("timestamp format must not be empty") + } + cfg.TimesFmt = value + default: + return fmt.Errorf("unknown mode field: %s", field) + } + + d.host.Statusf("[mode] %s=%q", field, value) + return nil +} + +func parseOnOff(v string) (bool, bool) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "on", "true", "1", "yes": + return true, true + case "off", "false", "0", "no": + return false, true + default: + return false, false + } +} diff --git a/internal/command/complete.go b/internal/command/complete.go new file mode 100644 index 0000000..c09b12e --- /dev/null +++ b/internal/command/complete.go @@ -0,0 +1,67 @@ +package command + +import "strings" + +func completeFirstToken(line, token string, cands []string) (string, []string) { + matches := filterPrefix(cands, token) + if len(matches) == 0 { + return line, nil + } + if len(matches) == 1 { + prefix := strings.TrimSuffix(line, token) + return prefix + matches[0] + " ", matches + } + return line, matches +} + +func filterPrefix(cands []string, cur string) []string { + if cur == "" { + return append([]string{}, cands...) + } + res := make([]string, 0, len(cands)) + for _, c := range cands { + if strings.HasPrefix(strings.ToLower(c), strings.ToLower(cur)) { + res = append(res, c) + } + } + return res +} + +func completeForward(args []string) []string { + if len(args) <= 2 { + return []string{"list", "add", "remove", "enable", "disable", "update", "stats"} + } + + if len(args) == 3 && args[1] == "add" { + return []string{"tcp", "udp"} + } + + if len(args) == 4 && args[1] == "update" { + return []string{"tcp", "udp"} + } + + return nil +} + +func completePlugin(args []string) []string { + if len(args) <= 2 { + return []string{"list", "load", "unload", "enable", "disable", "reload"} + } + return nil +} + +func completeMode(args []string) []string { + if len(args) <= 2 { + return []string{"show", "set"} + } + + if len(args) == 3 && args[1] == "set" { + return []string{"in", "out", "end", "frame", "timestamp", "timefmt"} + } + + if len(args) == 4 && args[1] == "set" && args[2] == "timestamp" { + return []string{"on", "off"} + } + + return nil +} diff --git a/internal/command/dispatcher.go b/internal/command/dispatcher.go new file mode 100644 index 0000000..9f1f7d8 --- /dev/null +++ b/internal/command/dispatcher.go @@ -0,0 +1,217 @@ +package command + +import ( + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" +) + +// CommandHost is the minimal interface the command dispatcher needs from its host. +type CommandHost interface { + Close() + Notifyf(format string, args ...any) + Statusf(format string, args ...any) + ShowModal(title, text string) + OpenPanel(panel event.UIPanelKind) + UIEnabled() bool + WriteToSession(data []byte) error + Forward() *forward.Manager + Plugins() *luaplugin.Manager + Cfg() *config.Config +} + +type CommandHandler func(args []string) error +type CommandCompleter func(args []string) []string + +type RuntimeCommand struct { + Name string + Usage string + Description string + Handler CommandHandler + Completer CommandCompleter +} + +type Dispatcher struct { + host CommandHost + commands map[string]*RuntimeCommand + order []string +} + +func NewDispatcher(host CommandHost) *Dispatcher { + d := &Dispatcher{ + host: host, + commands: make(map[string]*RuntimeCommand), + } + d.registerAll() + return d +} + +func (d *Dispatcher) register(cmd RuntimeCommand) { + key := strings.ToLower(cmd.Name) + d.commands[key] = &cmd + d.order = append(d.order, key) +} + +func (d *Dispatcher) registerAll() { + d.register(RuntimeCommand{ + Name: ".help", + Usage: ".help", + Description: "show command help", + Handler: func(args []string) error { + d.host.ShowModal("Command Help", d.HelpText()) + return nil + }, + }) + + d.register(RuntimeCommand{ + Name: ".exit", + Usage: ".exit", + Description: "exit local terminal", + Handler: func(args []string) error { + d.host.Statusf("[local] exiting") + d.host.Close() + return nil + }, + }) + + d.register(RuntimeCommand{ + Name: ".hex", + Usage: ".hex ", + Description: "send raw hex bytes", + Handler: func(args []string) error { + if len(args) < 2 { + return fmt.Errorf("usage: .hex ") + } + hexStr := strings.Join(args[1:], "") + b, err := hex.DecodeString(hexStr) + if err != nil { + return err + } + return d.host.WriteToSession(b) + }, + }) + + d.register(RuntimeCommand{ + Name: ".forward", + Usage: ".forward ", + Description: "manage forwarding at runtime", + Handler: d.handleForwardCommand, + Completer: completeForward, + }) + + d.register(RuntimeCommand{ + Name: ".plugin", + Usage: ".plugin ", + Description: "manage lua plugins", + Handler: d.handlePluginCommand, + Completer: completePlugin, + }) + + d.register(RuntimeCommand{ + Name: ".mode", + Usage: ".mode ", + Description: "show or update runtime terminal mode", + Handler: func(args []string) error { + return d.handleModeCommand(args) + }, + Completer: completeMode, + }) +} + +func (d *Dispatcher) Execute(line string) (bool, error) { + args := strings.Fields(strings.TrimSpace(line)) + if len(args) == 0 { + return false, nil + } + if !strings.HasPrefix(args[0], ".") { + return false, nil + } + + cmd, ok := d.commands[strings.ToLower(args[0])] + if !ok { + return true, fmt.Errorf("unknown command: %s", args[0]) + } + + if err := cmd.Handler(args); err != nil { + return true, err + } + return true, nil +} + +func (d *Dispatcher) HelpText() string { + keys := make([]string, 0, len(d.order)) + for _, k := range d.order { + keys = append(keys, k) + } + sort.Strings(keys) + + var b strings.Builder + b.WriteString("Commands:\n") + for _, k := range keys { + cmd := d.commands[k] + b.WriteString(fmt.Sprintf(" %-12s %-40s %s\n", cmd.Name, cmd.Usage, cmd.Description)) + } + return b.String() +} + +func (d *Dispatcher) Complete(line string) (string, []string) { + trimmed := strings.TrimLeft(line, " ") + if trimmed == "" { + return line, nil + } + + args := strings.Fields(trimmed) + endsWithSpace := strings.HasSuffix(line, " ") + + if len(args) == 0 { + return line, nil + } + + if len(args) == 1 && !endsWithSpace { + return completeFirstToken(line, args[0], d.commandNames()) + } + + cmdName := strings.ToLower(args[0]) + cmd, ok := d.commands[cmdName] + if !ok || cmd.Completer == nil { + return line, nil + } + + compArgs := args + if endsWithSpace { + compArgs = append(compArgs, "") + } + + cands := cmd.Completer(compArgs) + if len(cands) == 0 { + return line, nil + } + + current := compArgs[len(compArgs)-1] + base := strings.TrimSuffix(line, current) + + matches := filterPrefix(cands, current) + if len(matches) == 0 { + matches = cands + } + if len(matches) == 1 { + return base + matches[0], matches + } + + return line, matches +} + +func (d *Dispatcher) commandNames() []string { + names := make([]string, 0, len(d.commands)) + for _, cmd := range d.commands { + names = append(names, cmd.Name) + } + sort.Strings(names) + return names +} diff --git a/internal/termapp/main.go b/internal/console/console.go similarity index 55% rename from internal/termapp/main.go rename to internal/console/console.go index 3e57172..dd4f521 100644 --- a/internal/termapp/main.go +++ b/internal/console/console.go @@ -1,26 +1,25 @@ -package termapp +// Package console provides the non-TUI console mode. +package console import ( "fmt" - tea "github.com/charmbracelet/bubbletea" - "github.com/spf13/pflag" "io" - "log" "os" "os/signal" "strconv" "strings" + tea "github.com/charmbracelet/bubbletea" + "golang.org/x/term" + + apppkg "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/app" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/flag" "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" - "golang.org/x/term" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/tui" ) -func init() { - log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile | log.Lmsgprefix) - flag.Init(cfg) -} - +// Run parses flags, sets up the session and app, then runs TUI or console mode. func Run() { defer func() { if r := recover(); r != nil { @@ -29,12 +28,15 @@ func Run() { } }() + cfg := &config.Config{} + flag.Init(cfg) flag.Normalize() - pflag.Parse() + flag.Parse() flag.Ext(cfg) if cfg.PortName == "" { flag.GetCliFlag(cfg) } + ports, err := session.CheckPortAvailability(cfg.PortName) if err != nil { fmt.Println(err) @@ -42,27 +44,27 @@ func Run() { os.Exit(0) } - sess, err = session.Open(cfg) + sess, err := session.Open(cfg) if err != nil { fmt.Fprintf(os.Stderr, "open session failed: %v\n", err) os.Exit(1) } - app, err := NewApp(cfg) + appInst, err := apppkg.New(cfg, sess, os.Stdout) if err != nil { fmt.Fprintf(os.Stderr, "create app failed: %v\n", err) os.Exit(1) } - defer app.Close() + defer appInst.Close() - app.loadConfiguredForwards() - app.startOutputLoop() + appInst.LoadConfiguredForwards() + appInst.StartOutputLoop() - go forwardInterruptToRemote(app) - app.SetUIEnabled(cfg.EnableGUI) + go forwardInterruptToRemote(appInst) + appInst.SetUIEnabled(cfg.EnableGUI) if cfg.EnableGUI { - model := newUIModel(app) + model := tui.New(appInst) p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler()) if _, err = p.Run(); err != nil { fmt.Fprintf(os.Stderr, "tui failed: %v\n", err) @@ -71,32 +73,33 @@ func Run() { return } - if err = runConsole(app); err != nil { + if err = RunConsole(appInst); err != nil { fmt.Fprintf(os.Stderr, "console failed: %v\n", err) os.Exit(1) } } -func forwardInterruptToRemote(app *App) { +func forwardInterruptToRemote(appInst *apppkg.App) { sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt) defer signal.Stop(sigCh) for { select { - case <-app.waitDone(): + case <-appInst.WaitDone(): return case <-sigCh: - if err := app.sendCtrl('c'); err != nil { - app.Notifyf("[signal] interrupt pass-through failed: %v", err) + if err := appInst.SendCtrl('c'); err != nil { + appInst.Notifyf("[signal] interrupt pass-through failed: %v", err) continue } - app.Notifyf("[signal] Ctrl+C forwarded to remote") + appInst.Notifyf("[signal] Ctrl+C forwarded to remote") } } } -func runConsole(app *App) error { +// RunConsole runs the non-TUI console mode. +func RunConsole(appInst *apppkg.App) error { fd := int(os.Stdin.Fd()) isTerm := term.IsTerminal(fd) var oldState *term.State @@ -107,15 +110,12 @@ func runConsole(app *App) error { if err != nil { return err } - defer func() { - _ = term.Restore(fd, oldState) - }() + defer func() { _ = term.Restore(fd, oldState) }() } - app.Notifyf("[console] non-gui mode, commands start with '.' at line start\n") - app.Notifyf("[console] Ctrl+ passes through to remote; .exit to exit") + appInst.Notifyf("[console] non-gui mode, commands start with '.' at line start\n") + appInst.Notifyf("[console] Ctrl+ passes through to remote; .exit to exit") - // Read with a larger buffer so multi-byte sequences (arrows, CSI) arrive together. ch := make(chan byte, 1024) errCh := make(chan error, 1) go func() { @@ -132,6 +132,8 @@ func runConsole(app *App) error { } }() + out := appInst.Out() + cfg := appInst.Cfg() lineStart := true commandMode := false cmdBuf := make([]byte, 0, 128) @@ -147,7 +149,7 @@ func runConsole(app *App) error { readByte := func() (byte, error) { select { - case <-app.waitDone(): + case <-appInst.WaitDone(): return 0, io.EOF case rdErr := <-errCh: return 0, rdErr @@ -156,14 +158,13 @@ func runConsole(app *App) error { } } - // flushESC sends a fully-built escape sequence to serial. flushESC := func(seq []byte) bool { - if isExitHotkeySeq(seq) { - app.Close() + if isExitHotkeySeq(seq, cfg) { + appInst.Close() return true } - if err = app.writeToSession(seq); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession(seq); err != nil { + appInst.Statusf("[send] %v", err) } return false } @@ -177,39 +178,32 @@ func runConsole(app *App) error { return rdErr } - // ── Escape sequences (VT / CSI) ── if b == 0x1b { - // Try to read the rest without blocking. escBuf := []byte{0x1b} for { nb, ok := tryRead() if !ok { - // Standalone ESC — send it now. - if err = app.writeToSession([]byte{0x1b}); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession([]byte{0x1b}); err != nil { + appInst.Statusf("[send] %v", err) } break } escBuf = append(escBuf, nb) - // CSI terminator byte (0x40–0x7E): A–Z, a–z, ~, etc. if nb >= 0x40 && nb <= 0x7e { if flushESC(escBuf) { return nil } break } - // Short non-CSI sequence (e.g. ESC c). if len(escBuf) == 2 && escBuf[1] != '[' { if flushESC(escBuf) { return nil } break } - // CSI parameter bytes (digits, semicolons, etc.) — keep collecting. if len(escBuf) > 16 { - // Too long, just flush. - if err = app.writeToSession(escBuf); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession(escBuf); err != nil { + appInst.Statusf("[send] %v", err) } break } @@ -217,20 +211,18 @@ func runConsole(app *App) error { continue } - // ── Windows Alt+key: NULL prefix ── if b == 0x00 { if b2, ok := tryRead(); ok { - if isAltKeyExit(b2) { - app.Close() + if isAltKeyExit(b2, cfg) { + appInst.Close() return nil } - if err = app.writeToSession([]byte{0x00, b2}); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession([]byte{0x00, b2}); err != nil { + appInst.Statusf("[send] %v", err) } } else { - // No second byte available — send NULL alone. - if err = app.writeToSession([]byte{0x00}); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession([]byte{0x00}); err != nil { + appInst.Statusf("[send] %v", err) } } if commandMode { @@ -239,14 +231,13 @@ func runConsole(app *App) error { continue } - // ── Command mode ── if commandMode { switch b { case '\r', '\n': - echoConsoleNewline() + echoConsoleNewline(out) line := string(cmdBuf) if strings.TrimSpace(line) != "" { - app.handleLine(line) + appInst.HandleLine(line) } commandMode = false cmdBuf = cmdBuf[:0] @@ -254,42 +245,41 @@ func runConsole(app *App) error { case 0x7f, 0x08: if len(cmdBuf) > 0 { cmdBuf = cmdBuf[:len(cmdBuf)-1] - echoConsoleBackspace() + echoConsoleBackspace(out) } - case 0x09: // Tab — command completion - line, cands := app.dispatcher.Complete(string(cmdBuf)) + case 0x09: + line, cands := appInst.Dispatcher().Complete(string(cmdBuf)) if len(cands) == 1 { cmdBuf = append(cmdBuf[:0], line...) - echoRedrawCommand(line) + echoRedrawCommand(out, line) } else if len(cands) > 1 { - echoConsoleNewline() - app.Notifyf("%s", strings.Join(cands, " ")) - echoConsoleByte('.') - echoConsoleString(string(cmdBuf[1:])) + echoConsoleNewline(out) + appInst.Notifyf("%s", strings.Join(cands, " ")) + echoConsoleByte(out, '.') + echoConsoleString(out, string(cmdBuf[1:])) } default: cmdBuf = append(cmdBuf, b) - echoConsoleByte(b) + echoConsoleByte(out, b) } continue } - // ── Normal mode (sending to remote) ── if lineStart && b == '.' { commandMode = true cmdBuf = append(cmdBuf[:0], b) - echoConsoleByte(b) + echoConsoleByte(out, b) continue } if b == '\r' || b == '\n' { - if err = app.writeToSession([]byte(cfg.EndStr)); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession([]byte(cfg.EndStr)); err != nil { + appInst.Statusf("[send] %v", err) } lineStart = true } else { - if err = app.writeToSession([]byte{b}); err != nil { - app.Statusf("[send] %v", err) + if err = appInst.WriteToSession([]byte{b}); err != nil { + appInst.Statusf("[send] %v", err) } lineStart = false } @@ -297,7 +287,6 @@ func runConsole(app *App) error { } func parseCSIu(seq []byte) (cp int, mod int, ok bool) { - // ESC [ codepoint ; modifier u if len(seq) < 6 { return 0, 0, false } @@ -323,20 +312,15 @@ func parseCSIu(seq []byte) (cp int, mod int, ok bool) { return cp, mod, true } -func isAltKeyExit(b byte) bool { - if normalizeHotkeyPrefix(cfg.HotkeyMod) != "ctrl+alt" { +func isAltKeyExit(b byte, cfg *config.Config) bool { + if normalizeHotkey(cfg.HotkeyMod) != "ctrl+alt" { return false } - // 0x2E = scan code for 'C', 0x03 = Ctrl+C, 0x63 = 'c', 0x43 = 'C' return b == 0x2e || b == 0x03 || b == 0x63 || b == 0x43 } -func isExitHotkeySeq(seq []byte) bool { - mod := normalizeHotkeyPrefix(cfg.HotkeyMod) - - // CSI u format: ESC [ codepoint ; modifier u - // Only matches when the Ctrl modifier bit (4) is present, - // distinguishing Ctrl+Alt+C from Alt+C alone. +func isExitHotkeySeq(seq []byte, cfg *config.Config) bool { + mod := normalizeHotkey(cfg.HotkeyMod) if cp, cmod, ok := parseCSIu(seq); ok { if cp != 'c' && cp != 'C' { return false @@ -349,26 +333,19 @@ func isExitHotkeySeq(seq []byte) bool { } return false } - return false } -func echoConsoleByte(b byte) { - _, _ = out.Write([]byte{b}) +func normalizeHotkey(mod string) string { + mod = strings.ToLower(strings.TrimSpace(mod)) + if mod != "ctrl+alt" && mod != "ctrl+shift" { + mod = "ctrl+alt" + } + return mod } -func echoConsoleNewline() { - _, _ = io.WriteString(out, "\r\n") -} - -func echoConsoleBackspace() { - _, _ = io.WriteString(out, "\b \b") -} - -func echoConsoleString(s string) { - _, _ = io.WriteString(out, s) -} - -func echoRedrawCommand(s string) { - _, _ = io.WriteString(out, "\r\033[K> "+s) -} +func echoConsoleByte(out io.Writer, b byte) { _, _ = out.Write([]byte{b}) } +func echoConsoleNewline(out io.Writer) { _, _ = io.WriteString(out, "\r\n") } +func echoConsoleBackspace(out io.Writer) { _, _ = io.WriteString(out, "\b \b") } +func echoConsoleString(out io.Writer, s string) { _, _ = io.WriteString(out, s) } +func echoRedrawCommand(out io.Writer, s string) { _, _ = io.WriteString(out, "\r\033[K> "+s) } diff --git a/internal/termapp/main_other.go b/internal/console/console_other.go similarity index 76% rename from internal/termapp/main_other.go rename to internal/console/console_other.go index ec4cefd..a5e3874 100644 --- a/internal/termapp/main_other.go +++ b/internal/console/console_other.go @@ -1,5 +1,5 @@ //go:build !windows -package termapp +package console func enableVTInput(fd int) {} diff --git a/internal/console/console_windows.go b/internal/console/console_windows.go new file mode 100644 index 0000000..9f38bcc --- /dev/null +++ b/internal/console/console_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package console + +import "golang.org/x/sys/windows" + +func enableVTInput(fd int) { + var mode uint32 + if err := windows.GetConsoleMode(windows.Handle(fd), &mode); err != nil { + return + } + mode |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT + _ = windows.SetConsoleMode(windows.Handle(fd), mode) +} diff --git a/internal/flag/flag.go b/internal/flag/flag.go index d30e351..a5356f8 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -45,6 +45,10 @@ func Init(cfg *config.Config) { _ = pflag.Lookup("time") // mark for NoOptDefVal } +// Normalize converts single-dash long flags (e.g. -port) to double-dash (--port). +// Parse wraps pflag.Parse. +func Parse() { pflag.Parse() } + // Normalize converts single-dash long flags (e.g. -port) to double-dash (--port). func Normalize() { known := map[string]bool{ diff --git a/internal/termapp/app_test.go b/internal/termapp/app_test.go deleted file mode 100644 index 50ad0f3..0000000 --- a/internal/termapp/app_test.go +++ /dev/null @@ -1,259 +0,0 @@ -package termapp - -import ( - "io" - "net" - "testing" - "time" - - "go.bug.st/serial" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" -) - -func TestPrefixLines(t *testing.T) { - tests := []struct { - name string - in string - prefix string - want string - }{ - {name: "empty", in: "", prefix: "X ", want: ""}, - {name: "no-prefix", in: "a\n", prefix: "", want: "a\n"}, - {name: "single-line", in: "abc", prefix: "T ", want: "T abc"}, - {name: "multi-line", in: "a\nb\n", prefix: "P ", want: "P a\nP b\n"}, - } - - for _, tt := range tests { - got := prefixLines(tt.in, tt.prefix) - if got != tt.want { - t.Fatalf("%s: prefixLines got=%q want=%q", tt.name, got, tt.want) - } - } -} - -func TestAppUIEvents(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 8)} - a.SetUIEnabled(true) - - a.Notifyf("hello %s", "world") - a.Statusf("ok") - a.ShowModal("Title", "Body") - - ev1 := mustReadEvent(t, a.uiEvents) - if ev1.Kind != event.UIEventOutput || ev1.Text != "hello world" { - t.Fatalf("unexpected output event: %+v", ev1) - } - - ev2 := mustReadEvent(t, a.uiEvents) - if ev2.Kind != event.UIEventStatus || ev2.Text != "ok" { - t.Fatalf("unexpected status event: %+v", ev2) - } - - ev3 := mustReadEvent(t, a.uiEvents) - if ev3.Kind != event.UIEventModal || ev3.Title != "Title" || ev3.Text != "Body" { - t.Fatalf("unexpected modal event: %+v", ev3) - } -} - -func TestSendLine(t *testing.T) { - setupTestPipes() - a := &App{ - cfg: &Config{EndStr: "\r\n"}, - plugins: luaplugin.NewManager(), - uiEvents: make(chan event.UIEvent, 8), - done: make(chan struct{}), - } - a.SetUIEnabled(true) - - if err := a.sendLine("hello"); err != nil { - t.Fatalf("sendLine failed: %v", err) - } - - if err := a.sendLine(""); err != nil { - t.Fatalf("sendLine empty string should be no-op: %v", err) - } - if err := a.sendLine(" "); err != nil { - t.Fatalf("sendLine whitespace should be no-op: %v", err) - } -} - -func TestHandleLine(t *testing.T) { - setupTestPipes() - a := &App{ - cfg: &Config{EndStr: "\n", InputCode: "UTF-8", OutputCode: "UTF-8"}, - plugins: luaplugin.NewManager(), - uiEvents: make(chan event.UIEvent, 8), - done: make(chan struct{}), - } - a.SetUIEnabled(true) - a.forward = forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}) - a.dispatcher = NewCommandDispatcher(a) - - a.handleLine("hello") - a.handleLine("") - a.handleLine(".help") - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventModal || ev.Title == "" { - t.Fatalf("expected .help modal, got %+v", ev) - } -} - -func TestEmitNonUI(t *testing.T) { - oldOut := out - out = io.Discard - defer func() { out = oldOut }() - - a := &App{ - uiEvents: make(chan event.UIEvent, 4), - logFile: nil, - } - a.SetUIEnabled(false) - - a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "serial data\n"}) - a.emit(event.UIEvent{Kind: event.UIEventStatus, Text: "status msg"}) - a.emit(event.UIEvent{Kind: event.UIEventModal, Title: "T", Text: "body"}) - a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: ""}) -} - -func TestEmitUISaturation(t *testing.T) { - a := &App{ - uiEvents: make(chan event.UIEvent, 2), - } - a.SetUIEnabled(true) - - // Fill channel - a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "a"}) - a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "b"}) - // This should drop oldest and insert newest - a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: "c"}) - - ev := mustReadEvent(t, a.uiEvents) - if ev.Text != "b" { - t.Fatalf("expected b after drop, got %q", ev.Text) - } - ev = mustReadEvent(t, a.uiEvents) - if ev.Text != "c" { - t.Fatalf("expected c, got %q", ev.Text) - } -} - -func TestAppClose(t *testing.T) { - a := &App{ - done: make(chan struct{}), - plugins: luaplugin.NewManager(), - forward: forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}), - uiEvents: make(chan event.UIEvent, 4), - } - a.SetUIEnabled(true) - - a.Close() - if !a.isClosed() { - t.Fatalf("expected app closed") - } - // Second close should be safe - a.Close() -} - -func TestLoadConfiguredForwards(t *testing.T) { - listener, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen failed: %v", err) - } - defer listener.Close() - - testCfg := &Config{ - ForWard: []int{int(forward.TCP), int(forward.None), int(forward.UDP)}, - Address: []string{listener.Addr().String(), "", ""}, - } - - a := &App{ - cfg: testCfg, - forward: forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}), - uiEvents: make(chan event.UIEvent, 8), - done: make(chan struct{}), - } - a.SetUIEnabled(true) - - a.loadConfiguredForwards() - // forward.TCP should be added, forward.None skipped, forward.UDP skipped (empty address) - items := a.forward.List() - if len(items) != 1 || items[0].Mode != "tcp" { - t.Fatalf("expected 1 TCP forward, got %+v", items) - } -} - -func TestReportForwardIngress(t *testing.T) { - a := &App{ - cfg: &Config{InputCode: "UTF-8", OutputCode: "UTF-8"}, - uiEvents: make(chan event.UIEvent, 4), - } - a.SetUIEnabled(true) - - a.reportForwardIngress(1, []byte("test")) - - // Hex mode - a.cfg.InputCode = "hex" - a.reportForwardIngress(2, []byte{0x41, 0x42}) - - // Empty chunk - a.reportForwardIngress(3, nil) -} - -func TestSendCtrl(t *testing.T) { - if sess == nil { - sess = &session.SerialSession{} - } - oldSp := sess.Port - defer func() { sess.Port = oldSp }() - - // Use a mock serial port - sess.Port = &mockSerialPort{} - a := &App{ - cfg: &Config{}, - uiEvents: make(chan event.UIEvent, 4), - } - a.SetUIEnabled(true) - - if err := a.sendCtrl('c'); err != nil { - t.Fatalf("sendCtrl('c') failed: %v", err) - } - if err := a.sendCtrl('C'); err != nil { - t.Fatalf("sendCtrl('C') failed: %v", err) - } - if err := a.sendCtrl('A'); err != nil { - t.Fatalf("sendCtrl('A') failed: %v", err) - } -} - -type mockSerialPort struct{} - -func (m *mockSerialPort) Write(p []byte) (int, error) { return len(p), nil } -func (m *mockSerialPort) Read(p []byte) (int, error) { return 0, io.EOF } -func (m *mockSerialPort) Close() error { return nil } -func (m *mockSerialPort) SetMode(mode *serial.Mode) error { return nil } -func (m *mockSerialPort) SetDTR(dtr bool) error { return nil } -func (m *mockSerialPort) SetRTS(rts bool) error { return nil } -func (m *mockSerialPort) GetModemStatusBits() (*serial.ModemStatusBits, error) { - return &serial.ModemStatusBits{}, nil -} -func (m *mockSerialPort) ResetInputBuffer() error { return nil } -func (m *mockSerialPort) ResetOutputBuffer() error { return nil } -func (m *mockSerialPort) SetReadTimeout(t time.Duration) error { return nil } -func (m *mockSerialPort) Break(t time.Duration) error { return nil } -func (m *mockSerialPort) Drain() error { return nil } - -func mustReadEvent(t *testing.T, ch <-chan event.UIEvent) event.UIEvent { - t.Helper() - select { - case ev := <-ch: - return ev - case <-time.After(2 * time.Second): - t.Fatalf("timed out waiting for UI event") - return event.UIEvent{} - } -} diff --git a/internal/termapp/command.go b/internal/termapp/command.go deleted file mode 100644 index 0df8664..0000000 --- a/internal/termapp/command.go +++ /dev/null @@ -1,487 +0,0 @@ -package termapp - -import ( - "encoding/hex" - "fmt" - "sort" - "strconv" - "strings" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" -) - -type CommandHandler func(args []string) error -type CommandCompleter func(args []string) []string - -type RuntimeCommand struct { - Name string - Usage string - Description string - Handler CommandHandler - Completer CommandCompleter -} - -type CommandDispatcher struct { - app *App - commands map[string]*RuntimeCommand - order []string -} - -func NewCommandDispatcher(app *App) *CommandDispatcher { - d := &CommandDispatcher{ - app: app, - commands: make(map[string]*RuntimeCommand), - } - - d.registerAll() - return d -} - -func (d *CommandDispatcher) register(cmd RuntimeCommand) { - key := strings.ToLower(cmd.Name) - d.commands[key] = &cmd - d.order = append(d.order, key) -} - -func (d *CommandDispatcher) registerAll() { - d.register(RuntimeCommand{ - Name: ".help", - Usage: ".help", - Description: "show command help", - Handler: func(args []string) error { - d.app.ShowModal("Command Help", d.HelpText()) - return nil - }, - }) - - d.register(RuntimeCommand{ - Name: ".exit", - Usage: ".exit", - Description: "exit local terminal", - Handler: func(args []string) error { - d.app.Statusf("[local] exiting") - d.app.Close() - return nil - }, - }) - - d.register(RuntimeCommand{ - Name: ".hex", - Usage: ".hex ", - Description: "send raw hex bytes", - Handler: func(args []string) error { - if len(args) < 2 { - return fmt.Errorf("usage: .hex ") - } - hexStr := strings.Join(args[1:], "") - b, err := hex.DecodeString(hexStr) - if err != nil { - return err - } - return d.app.writeToSession(b) - }, - }) - - d.register(RuntimeCommand{ - Name: ".forward", - Usage: ".forward ", - Description: "manage forwarding at runtime", - Handler: d.handleForwardCommand, - Completer: completeForward, - }) - - d.register(RuntimeCommand{ - Name: ".plugin", - Usage: ".plugin ", - Description: "manage lua plugins", - Handler: d.handlePluginCommand, - Completer: completePlugin, - }) - - d.register(RuntimeCommand{ - Name: ".mode", - Usage: ".mode ", - Description: "show or update runtime terminal mode", - Handler: func(args []string) error { - return d.handleModeCommand(args) - }, - Completer: completeMode, - }) -} - -func (d *CommandDispatcher) Execute(line string) (bool, error) { - args := strings.Fields(strings.TrimSpace(line)) - if len(args) == 0 { - return false, nil - } - if !strings.HasPrefix(args[0], ".") { - return false, nil - } - - cmd, ok := d.commands[strings.ToLower(args[0])] - if !ok { - return true, fmt.Errorf("unknown command: %s", args[0]) - } - - if err := cmd.Handler(args); err != nil { - return true, err - } - return true, nil -} - -func (d *CommandDispatcher) HelpText() string { - keys := make([]string, 0, len(d.order)) - for _, k := range d.order { - keys = append(keys, k) - } - sort.Strings(keys) - - var b strings.Builder - b.WriteString("Commands:\n") - for _, k := range keys { - cmd := d.commands[k] - b.WriteString(fmt.Sprintf(" %-12s %-40s %s\n", cmd.Name, cmd.Usage, cmd.Description)) - } - return b.String() -} - -func (d *CommandDispatcher) Complete(line string) (string, []string) { - trimmed := strings.TrimLeft(line, " ") - if trimmed == "" { - return line, nil - } - - args := strings.Fields(trimmed) - endsWithSpace := strings.HasSuffix(line, " ") - - if len(args) == 0 { - return line, nil - } - - if len(args) == 1 && !endsWithSpace { - return completeFirstToken(line, args[0], d.commandNames()) - } - - cmdName := strings.ToLower(args[0]) - cmd, ok := d.commands[cmdName] - if !ok || cmd.Completer == nil { - return line, nil - } - - compArgs := args - if endsWithSpace { - compArgs = append(compArgs, "") - } - - cands := cmd.Completer(compArgs) - if len(cands) == 0 { - return line, nil - } - - current := compArgs[len(compArgs)-1] - base := strings.TrimSuffix(line, current) - - matches := filterPrefix(cands, current) - if len(matches) == 0 { - matches = cands - } - if len(matches) == 1 { - return base + matches[0], matches - } - - return line, matches -} - -func (d *CommandDispatcher) commandNames() []string { - names := make([]string, 0, len(d.commands)) - for _, cmd := range d.commands { - names = append(names, cmd.Name) - } - sort.Strings(names) - return names -} - -func completeFirstToken(line, token string, cands []string) (string, []string) { - matches := filterPrefix(cands, token) - if len(matches) == 0 { - return line, nil - } - if len(matches) == 1 { - prefix := strings.TrimSuffix(line, token) - return prefix + matches[0] + " ", matches - } - return line, matches -} - -func filterPrefix(cands []string, cur string) []string { - if cur == "" { - return append([]string{}, cands...) - } - res := make([]string, 0, len(cands)) - for _, c := range cands { - if strings.HasPrefix(strings.ToLower(c), strings.ToLower(cur)) { - res = append(res, c) - } - } - return res -} - -func completeForward(args []string) []string { - if len(args) <= 2 { - return []string{"list", "add", "remove", "enable", "disable", "update", "stats"} - } - - if len(args) == 3 && args[1] == "add" { - return []string{"tcp", "udp"} - } - - if len(args) == 4 && args[1] == "update" { - return []string{"tcp", "udp"} - } - - return nil -} - -func completePlugin(args []string) []string { - if len(args) <= 2 { - return []string{"list", "load", "unload", "enable", "disable", "reload"} - } - return nil -} - -func completeMode(args []string) []string { - if len(args) <= 2 { - return []string{"show", "set"} - } - - if len(args) == 3 && args[1] == "set" { - return []string{"in", "out", "end", "frame", "timestamp", "timefmt"} - } - - if len(args) == 4 && args[1] == "set" && args[2] == "timestamp" { - return []string{"on", "off"} - } - - return nil -} - -func (d *CommandDispatcher) handleForwardCommand(args []string) error { - if len(args) < 2 { - if d.app.UIEnabled() { - d.app.OpenPanel(event.UIPanelForward) - return nil - } - args = []string{".forward", "list"} - } - - sub := strings.ToLower(args[1]) - switch sub { - case "list", "stats": - if d.app.UIEnabled() { - d.app.OpenPanel(event.UIPanelForward) - return nil - } - - items := d.app.forward.List() - if len(items) == 0 { - d.app.Notifyf("[forward] empty") - return nil - } - d.app.Notifyf("[forward] ID Mode Enabled Connected Address InBytes OutBytes LastError") - for _, it := range items { - d.app.Notifyf("[forward] %d %s %v %v %s %d %d %s", it.ID, it.Mode, it.Enabled, it.Connected, it.Address, it.ReadBytes, it.WriteByte, it.LastError) - } - return nil - - case "add": - if len(args) < 4 { - return fmt.Errorf("usage: .forward add
") - } - mode, ok := forward.ParseMode(args[2]) - if !ok { - return fmt.Errorf("unknown forward mode: %s", args[2]) - } - id, err := d.app.forward.Add(mode, args[3]) - if err != nil { - return err - } - d.app.Statusf("[forward] added #%d", id) - return nil - - case "remove", "enable", "disable": - if len(args) < 3 { - return fmt.Errorf("usage: .forward %s ", sub) - } - id, err := strconv.Atoi(args[2]) - if err != nil { - return err - } - switch sub { - case "remove": - return d.app.forward.Remove(id) - case "enable": - return d.app.forward.Enable(id) - case "disable": - return d.app.forward.Disable(id) - } - - case "update": - if len(args) < 5 { - return fmt.Errorf("usage: .forward update
") - } - id, err := strconv.Atoi(args[2]) - if err != nil { - return err - } - mode, ok := forward.ParseMode(args[3]) - if !ok { - return fmt.Errorf("unknown forward mode: %s", args[3]) - } - if err = d.app.forward.Update(id, mode, args[4]); err != nil { - return err - } - d.app.Statusf("[forward] updated #%d", id) - return nil - } - - return fmt.Errorf("unknown subcommand: %s", sub) -} - -func (d *CommandDispatcher) handlePluginCommand(args []string) error { - if len(args) < 2 { - if d.app.UIEnabled() { - d.app.OpenPanel(event.UIPanelPlugin) - return nil - } - args = []string{".plugin", "list"} - } - - sub := strings.ToLower(args[1]) - switch sub { - case "list": - if d.app.UIEnabled() { - d.app.OpenPanel(event.UIPanelPlugin) - return nil - } - - items := d.app.plugins.List() - if len(items) == 0 { - d.app.Notifyf("[plugin] empty") - return nil - } - for _, it := range items { - d.app.Notifyf("[plugin] %s enabled=%v path=%s", it.Name, it.Enabled, it.Path) - } - return nil - - case "load": - if len(args) < 3 { - return fmt.Errorf("usage: .plugin load ") - } - name, err := d.app.plugins.Load(args[2]) - if err != nil { - return err - } - d.app.Statusf("[plugin] loaded %s", name) - return nil - - case "unload", "enable", "disable", "reload": - if len(args) < 3 { - return fmt.Errorf("usage: .plugin %s ", sub) - } - name := args[2] - switch sub { - case "unload": - return d.app.plugins.Unload(name) - case "enable": - return d.app.plugins.Enable(name) - case "disable": - return d.app.plugins.Disable(name) - case "reload": - return d.app.plugins.Reload(name) - } - } - - return fmt.Errorf("unknown subcommand: %s", sub) -} - -func (d *CommandDispatcher) handleModeCommand(args []string) error { - if len(args) < 2 || strings.EqualFold(args[1], "show") { - if d.app.UIEnabled() { - d.app.OpenPanel(event.UIPanelMode) - return nil - } - - d.app.Notifyf("[mode] input=%s output=%s end=%q hex=%v frame=%d timestamp=%v timefmt=%q forwardTargets=%d plugins=%d", - d.app.cfg.InputCode, - d.app.cfg.OutputCode, - d.app.cfg.EndStr, - strings.EqualFold(d.app.cfg.InputCode, "hex"), - d.app.cfg.FrameSize, - d.app.cfg.TimesTamp, - d.app.cfg.TimesFmt, - len(d.app.forward.List()), - len(d.app.plugins.List()), - ) - return nil - } - - if !strings.EqualFold(args[1], "set") { - return fmt.Errorf("usage: .mode ") - } - if len(args) < 4 { - return fmt.Errorf("usage: .mode set ") - } - - field := strings.ToLower(args[2]) - value := strings.Join(args[3:], " ") - - switch field { - case "in": - if value == "" { - return fmt.Errorf("input charset must not be empty") - } - d.app.cfg.InputCode = value - case "out": - if value == "" { - return fmt.Errorf("output charset must not be empty") - } - d.app.cfg.OutputCode = value - case "end": - d.app.cfg.EndStr = value - case "frame": - n, err := strconv.Atoi(value) - if err != nil || n <= 0 { - return fmt.Errorf("frame must be a positive integer") - } - d.app.cfg.FrameSize = n - case "timestamp": - enabled, ok := parseOnOff(value) - if !ok { - return fmt.Errorf("timestamp value must be on/off") - } - d.app.cfg.TimesTamp = enabled - case "timefmt": - if value == "" && d.app.cfg.TimesTamp { - return fmt.Errorf("timestamp format must not be empty") - } - d.app.cfg.TimesFmt = value - default: - return fmt.Errorf("unknown mode field: %s", field) - } - - d.app.Statusf("[mode] %s=%q", field, value) - return nil -} - -func parseOnOff(v string) (bool, bool) { - switch strings.ToLower(strings.TrimSpace(v)) { - case "on", "true", "1", "yes": - return true, true - case "off", "false", "0", "no": - return false, true - default: - return false, false - } -} diff --git a/internal/termapp/command_test.go b/internal/termapp/command_test.go deleted file mode 100644 index a309e73..0000000 --- a/internal/termapp/command_test.go +++ /dev/null @@ -1,502 +0,0 @@ -package termapp - -import ( - "io" - "strings" - "testing" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin" -) - -func setupTestPipes() { - if sess == nil { - sess = &session.SerialSession{} - } - var cr *io.PipeReader - cr, sess.StdinPipe = io.Pipe() - go func() { - buf := make([]byte, 4096) - for { - _, err := cr.Read(buf) - if err != nil { - return - } - } - }() -} - -func newTestAppForCommand() *App { - a := &App{ - cfg: &Config{InputCode: "UTF-8", OutputCode: "UTF-8", EndStr: "\n"}, - plugins: luaplugin.NewManager(), - uiEvents: make(chan event.UIEvent, 32), - done: make(chan struct{}), - } - a.SetUIEnabled(true) - a.forward = forward.NewManager(func([]byte) error { return nil }, func(string, ...any) {}) - a.dispatcher = NewCommandDispatcher(a) - return a -} - -func TestCommandCompleteRoot(t *testing.T) { - a := newTestAppForCommand() - line, cands := a.dispatcher.Complete(".") - if line != "." { - t.Fatalf("expected line unchanged for ambiguous completion, got %q", line) - } - if len(cands) == 0 { - t.Fatalf("expected root command candidates") - } - for _, c := range cands { - if c == ".ctrl" { - t.Fatalf(".ctrl should be removed from command set") - } - } -} - -func TestCommandCompleteForwardSubcommands(t *testing.T) { - a := newTestAppForCommand() - _, cands := a.dispatcher.Complete(".forward ") - joined := strings.Join(cands, ",") - for _, name := range []string{"list", "add", "remove", "enable", "disable", "update", "stats"} { - if !strings.Contains(joined, name) { - t.Fatalf("missing forward candidate %q in %v", name, cands) - } - } -} - -func TestCommandExecuteUnknown(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".unknown") - if !handled { - t.Fatalf("unknown command should be marked handled") - } - if err == nil { - t.Fatalf("expected unknown command error") - } -} - -func TestCommandExecuteHelpShowsModal(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".help") - if err != nil || !handled { - t.Fatalf(".help execute failed handled=%v err=%v", handled, err) - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventModal || ev.Title == "" { - t.Fatalf("expected help modal event, got %+v", ev) - } -} - -func TestCommandExecuteForwardListShowsPanel(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".forward list") - if err != nil || !handled { - t.Fatalf(".forward list execute failed handled=%v err=%v", handled, err) - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelForward { - t.Fatalf("expected forward panel event, got %+v", ev) - } -} - -func TestCommandExecutePluginListShowsPanel(t *testing.T) { - a := newTestAppForCommand() - if _, err := a.plugins.Load("plugins/demo.lua"); err == nil { - _ = a.plugins.Disable("demo") - } - handled, err := a.dispatcher.Execute(".plugin list") - if err != nil || !handled { - t.Fatalf(".plugin list execute failed handled=%v err=%v", handled, err) - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin { - t.Fatalf("expected plugin panel event, got %+v", ev) - } -} - -func TestCommandExecutePluginWithoutSubcommandShowsPanel(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".plugin") - if err != nil || !handled { - t.Fatalf(".plugin execute failed handled=%v err=%v", handled, err) - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin { - t.Fatalf("expected plugin panel event for bare command, got %+v", ev) - } -} - -func TestCommandExecuteModeShowsPanel(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".mode show") - if err != nil || !handled { - t.Fatalf(".mode execute failed handled=%v err=%v", handled, err) - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelMode { - t.Fatalf("expected mode panel event, got %+v", ev) - } -} - -func TestCommandExecuteModeSet(t *testing.T) { - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".mode set end \\r\\n") - if err != nil || !handled { - t.Fatalf(".mode set end failed handled=%v err=%v", handled, err) - } - if a.cfg.EndStr != "\\r\\n" { - t.Fatalf("mode set end not applied, got=%q", a.cfg.EndStr) - } - - handled, err = a.dispatcher.Execute(".mode set timestamp on") - if err != nil || !handled { - t.Fatalf(".mode set timestamp failed handled=%v err=%v", handled, err) - } - if !a.cfg.TimesTamp { - t.Fatalf("mode set timestamp should enable timesTamp") - } -} - -func TestParseOnOff(t *testing.T) { - tests := []struct { - in string - val bool - valid bool - }{ - {in: "on", val: true, valid: true}, - {in: "true", val: true, valid: true}, - {in: "1", val: true, valid: true}, - {in: "yes", val: true, valid: true}, - {in: "off", val: false, valid: true}, - {in: "false", val: false, valid: true}, - {in: "0", val: false, valid: true}, - {in: "no", val: false, valid: true}, - {in: "", val: false, valid: false}, - {in: "maybe", val: false, valid: false}, - } - - for _, tt := range tests { - got, ok := parseOnOff(tt.in) - if ok != tt.valid || got != tt.val { - t.Fatalf("parseOnOff(%q) got=(%v,%v) want=(%v,%v)", tt.in, got, ok, tt.val, tt.valid) - } - } -} - -func TestCompleteForward(t *testing.T) { - tests := []struct { - args []string - want []string - }{ - {args: []string{".forward"}, want: []string{"list", "add", "remove", "enable", "disable", "update", "stats"}}, - {args: []string{".forward", ""}, want: []string{"list", "add", "remove", "enable", "disable", "update", "stats"}}, - {args: []string{".forward", "add", ""}, want: []string{"tcp", "udp"}}, - {args: []string{".forward", "update", "1", ""}, want: []string{"tcp", "udp"}}, - {args: []string{".forward", "list", "1"}, want: nil}, - } - for _, tt := range tests { - got := completeForward(tt.args) - if !stringSlicesEqual(got, tt.want) { - t.Fatalf("completeForward(%v) got=%v want=%v", tt.args, got, tt.want) - } - } -} - -func TestCompletePlugin(t *testing.T) { - tests := []struct { - args []string - want []string - }{ - {args: []string{".plugin"}, want: []string{"list", "load", "unload", "enable", "disable", "reload"}}, - {args: []string{".plugin", "load", ""}, want: nil}, - {args: []string{".plugin", "unload", "demo"}, want: nil}, - } - for _, tt := range tests { - got := completePlugin(tt.args) - if !stringSlicesEqual(got, tt.want) { - t.Fatalf("completePlugin(%v) got=%v want=%v", tt.args, got, tt.want) - } - } -} - -func TestCompleteMode(t *testing.T) { - tests := []struct { - args []string - want []string - }{ - {args: []string{".mode"}, want: []string{"show", "set"}}, - {args: []string{".mode", "set", ""}, want: []string{"in", "out", "end", "frame", "timestamp", "timefmt"}}, - {args: []string{".mode", "set", "timestamp", ""}, want: []string{"on", "off"}}, - {args: []string{".mode", "set", "in", ""}, want: nil}, - } - for _, tt := range tests { - got := completeMode(tt.args) - if !stringSlicesEqual(got, tt.want) { - t.Fatalf("completeMode(%v) got=%v want=%v", tt.args, got, tt.want) - } - } -} - -func stringSlicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true -} - -func TestHelpText(t *testing.T) { - a := newTestAppForCommand() - text := a.dispatcher.HelpText() - for _, cmd := range []string{".help", ".exit", ".hex", ".forward", ".plugin", ".mode"} { - if !strings.Contains(text, cmd) { - t.Fatalf("HelpText missing command %q", cmd) - } - } -} - -func TestCommandExecuteHex(t *testing.T) { - setupTestPipes() - a := newTestAppForCommand() - handled, err := a.dispatcher.Execute(".hex 41 42 43") - if err != nil || !handled { - t.Fatalf(".hex valid failed handled=%v err=%v", handled, err) - } - - handled, err = a.dispatcher.Execute(".hex") - if !handled || err == nil { - t.Fatalf(".hex no args should error, handled=%v err=%v", handled, err) - } - - handled, err = a.dispatcher.Execute(".hex xyz") - if !handled || err == nil { - t.Fatalf(".hex invalid hex should error, handled=%v err=%v", handled, err) - } -} - -func TestCommandExecuteExit(t *testing.T) { - a := newTestAppForCommand() - a.Close() - if !a.isClosed() { - t.Fatalf("expected app closed after Close()") - } -} - -func TestCommandExecuteModeSetAll(t *testing.T) { - a := newTestAppForCommand() - - handled, err := a.dispatcher.Execute(".mode set frame 32") - if err != nil || !handled { - t.Fatalf(".mode set frame failed: handled=%v err=%v", handled, err) - } - if a.cfg.FrameSize != 32 { - t.Fatalf("frameSize not set, got=%d", a.cfg.FrameSize) - } - - handled, err = a.dispatcher.Execute(".mode set timefmt 2006") - if err != nil || !handled { - t.Fatalf(".mode set timefmt failed: handled=%v err=%v", handled, err) - } - if a.cfg.TimesFmt != "2006" { - t.Fatalf("timesFmt not set, got=%q", a.cfg.TimesFmt) - } - - handled, err = a.dispatcher.Execute(".mode set out GBK") - if err != nil || !handled { - t.Fatalf(".mode set out failed: handled=%v err=%v", handled, err) - } - if a.cfg.OutputCode != "GBK" { - t.Fatalf("outputCode not set, got=%q", a.cfg.OutputCode) - } - - handled, err = a.dispatcher.Execute(".mode set in GBK") - if err != nil || !handled { - t.Fatalf(".mode set in failed: handled=%v err=%v", handled, err) - } - if a.cfg.InputCode != "GBK" { - t.Fatalf("inputCode not set, got=%q", a.cfg.InputCode) - } -} - -func TestCommandExecuteModeErrors(t *testing.T) { - a := newTestAppForCommand() - - handled, err := a.dispatcher.Execute(".mode") - if err != nil || !handled { - t.Fatalf(".mode with no subcommand in UI mode shows panel, handled=%v err=%v", handled, err) - } - - _, err = a.dispatcher.Execute(".mode set") - if err == nil { - t.Fatalf(".mode set with no args should error") - } - - _, err = a.dispatcher.Execute(".mode set frame abc") - if err == nil { - t.Fatalf(".mode set frame with non-int should error") - } - - _, err = a.dispatcher.Execute(".mode set timestamp maybe") - if err == nil { - t.Fatalf(".mode set timestamp with invalid value should error") - } - - _, err = a.dispatcher.Execute(".mode set invalid_field value") - if err == nil { - t.Fatalf(".mode set unknown field should error") - } -} - -func TestHandleForwardCommandErrors(t *testing.T) { - a := newTestAppForCommand() - - _, err := a.dispatcher.Execute(".forward add") - if err == nil { - t.Fatalf(".forward add with no args should error") - } - - _, err = a.dispatcher.Execute(".forward add badmode 127.0.0.1:1") - if err == nil { - t.Fatalf(".forward add with invalid mode should error") - } - - _, err = a.dispatcher.Execute(".forward remove abc") - if err == nil { - t.Fatalf(".forward remove with non-int ID should error") - } - - _, err = a.dispatcher.Execute(".forward remove 999") - if err == nil { - t.Fatalf(".forward remove non-existing should error") - } - - _, err = a.dispatcher.Execute(".forward enable abc") - if err == nil { - t.Fatalf(".forward enable with non-int ID should error") - } - - _, err = a.dispatcher.Execute(".forward disable abc") - if err == nil { - t.Fatalf(".forward disable with non-int ID should error") - } - - _, err = a.dispatcher.Execute(".forward update") - if err == nil { - t.Fatalf(".forward update with no args should error") - } - - _, err = a.dispatcher.Execute(".forward update 1") - if err == nil { - t.Fatalf(".forward update with missing addr should error") - } - - _, err = a.dispatcher.Execute(".forward update 1 badmode 127.0.0.1:1") - if err == nil { - t.Fatalf(".forward update with invalid mode should error") - } - - _, err = a.dispatcher.Execute(".forward unknown_sub") - if err == nil { - t.Fatalf(".forward unknown subcommand should error") - } -} - -func TestHandleForwardCommandNoUI(t *testing.T) { - a := newTestAppForCommand() - a.SetUIEnabled(false) - - handled, err := a.dispatcher.Execute(".forward") - if err != nil || !handled { - t.Fatalf(".forward in non-UI should default to list, handled=%v err=%v", handled, err) - } - - handled, err = a.dispatcher.Execute(".forward list") - if err != nil || !handled { - t.Fatalf(".forward list in non-UI failed: %v", err) - } -} - -func TestHandlePluginCommandErrors(t *testing.T) { - a := newTestAppForCommand() - - _, err := a.dispatcher.Execute(".plugin load") - if err == nil { - t.Fatalf(".plugin load with no path should error") - } - - _, err = a.dispatcher.Execute(".plugin unload") - if err == nil { - t.Fatalf(".plugin unload with no name should error") - } - - _, err = a.dispatcher.Execute(".plugin enable") - if err == nil { - t.Fatalf(".plugin enable with no name should error") - } - - _, err = a.dispatcher.Execute(".plugin disable") - if err == nil { - t.Fatalf(".plugin disable with no name should error") - } - - _, err = a.dispatcher.Execute(".plugin reload") - if err == nil { - t.Fatalf(".plugin reload with no name should error") - } - - _, err = a.dispatcher.Execute(".plugin unknown_sub") - if err == nil { - t.Fatalf(".plugin unknown subcommand should error") - } -} - -func TestHandlePluginCommandNoUI(t *testing.T) { - a := newTestAppForCommand() - a.SetUIEnabled(false) - - handled, err := a.dispatcher.Execute(".plugin") - if err != nil || !handled { - t.Fatalf(".plugin in non-UI should default to list, handled=%v err=%v", handled, err) - } -} - -func TestCompleteFirstTokenEdgeCases(t *testing.T) { - a := newTestAppForCommand() - line, cands := a.dispatcher.Complete(".he") - if line != ".he" { - t.Fatalf("ambiguous completion should not change line, got=%q", line) - } - found := false - for _, c := range cands { - if c == ".help" { - found = true - break - } - } - if !found { - t.Fatalf("expected .help in completion candidates, got %v", cands) - } - - line, cands = a.dispatcher.Complete(".exi") - if line != ".exit " || len(cands) != 1 || cands[0] != ".exit" { - t.Fatalf("exact completion of .exi failed: line=%q cands=%v", line, cands) - } - - line, _ = a.dispatcher.Complete("") - if line != "" { - t.Fatalf("empty completion should be noop, got=%q", line) - } -} diff --git a/internal/termapp/config.go b/internal/termapp/config.go deleted file mode 100644 index 2edad96..0000000 --- a/internal/termapp/config.go +++ /dev/null @@ -1,10 +0,0 @@ -package termapp - -import ( - appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" -) - -// Config is an alias for appconfig.Config to keep main-package code concise. -type Config = appconfig.Config - -var cfg = &Config{} diff --git a/internal/termapp/config_test.go b/internal/termapp/config_test.go deleted file mode 100644 index ee6d062..0000000 --- a/internal/termapp/config_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package termapp - -import ( - "path/filepath" - "testing" - - appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config" - "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" -) - -func TestForwardModeNetworkAndString(t *testing.T) { - tests := []struct { - mode forward.Mode - network string - name string - }{ - {mode: forward.None, network: "", name: "none"}, - {mode: forward.TCP, network: "tcp", name: "tcp"}, - {mode: forward.UDP, network: "udp", name: "udp"}, - } - - for _, tt := range tests { - if got := tt.mode.Network(); got != tt.network { - t.Fatalf("Network() mode=%v got=%q want=%q", tt.mode, got, tt.network) - } - if got := tt.mode.String(); got != tt.name { - t.Fatalf("String() mode=%v got=%q want=%q", tt.mode, got, tt.name) - } - } -} - -func TestParseForwardMode(t *testing.T) { - tests := []struct { - input string - mode forward.Mode - ok bool - }{ - {input: "tcp", mode: forward.TCP, ok: true}, - {input: "TCP-C", mode: forward.TCP, ok: true}, - {input: "1", mode: forward.TCP, ok: true}, - {input: "udp", mode: forward.UDP, ok: true}, - {input: " 2 ", mode: forward.UDP, ok: true}, - {input: "unknown", mode: forward.None, ok: false}, - {input: "", mode: forward.None, ok: false}, - } - - for _, tt := range tests { - got, ok := forward.ParseMode(tt.input) - if ok != tt.ok || got != tt.mode { - t.Fatalf("forward.ParseMode(%q) got=(%v,%v) want=(%v,%v)", tt.input, got, ok, tt.mode, tt.ok) - } - } -} - -func TestOpenLogFile(t *testing.T) { - old := *cfg - defer func() { *cfg = old }() - - *cfg = Config{ - EnableLog: true, - PortName: "COM1", - LogFilePath: filepath.Join(t.TempDir(), "%s-%s.log"), - } - - f, err := appconfig.OpenLogFile(cfg) - if err != nil { - t.Fatalf("openLogFile() unexpected error: %v", err) - } - if f == nil { - t.Fatalf("openLogFile() got nil file when enableLog=true") - } - _ = f.Close() - - cfg.EnableLog = false - f, err = appconfig.OpenLogFile(cfg) - if err != nil { - t.Fatalf("openLogFile() unexpected error with enableLog=false: %v", err) - } - if f != nil { - t.Fatalf("openLogFile() expected nil file when enableLog=false") - } -} diff --git a/internal/termapp/escape_test.go b/internal/termapp/escape_test.go deleted file mode 100644 index 7dc51e8..0000000 --- a/internal/termapp/escape_test.go +++ /dev/null @@ -1,123 +0,0 @@ -package termapp - -import ( - "testing" -) - -func TestParseCSIu(t *testing.T) { - tests := []struct { - name string - seq []byte - cp int - mod int - ok bool - }{ - { - name: "ctrl+alt+c lowercase", - seq: []byte{0x1b, '[', '9', '9', ';', '6', 'u'}, - cp: 99, mod: 6, ok: true, - }, - { - name: "ctrl+shift+c uppercase", - seq: []byte{0x1b, '[', '6', '7', ';', '5', 'u'}, - cp: 67, mod: 5, ok: true, - }, - { - name: "too short", - seq: []byte{0x1b, '[', '9', '9'}, - cp: 0, mod: 0, ok: false, - }, - { - name: "no escape prefix", - seq: []byte{'[', '9', '9', ';', '6', 'u'}, - cp: 0, mod: 0, ok: false, - }, - { - name: "no u terminator", - seq: []byte{0x1b, '[', '9', '9', ';', '6', 'x'}, - cp: 0, mod: 0, ok: false, - }, - { - name: "bad format no semicolon", - seq: []byte{0x1b, '[', '9', '9', '6', 'u'}, - cp: 0, mod: 0, ok: false, - }, - { - name: "empty", - seq: []byte{}, - cp: 0, mod: 0, ok: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - cp, mod, ok := parseCSIu(tt.seq) - if ok != tt.ok || cp != tt.cp || mod != tt.mod { - t.Fatalf("parseCSIu(%v) got=(%d,%d,%v) want=(%d,%d,%v)", tt.seq, cp, mod, ok, tt.cp, tt.mod, tt.ok) - } - }) - } -} - -func TestIsExitHotkeySeq(t *testing.T) { - oldCfg := *cfg - defer func() { *cfg = oldCfg }() - - *cfg = Config{HotkeyMod: "ctrl+alt"} - - // CSI u Ctrl+Alt+C (mod=6) - if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}) { - t.Fatalf("Ctrl+Alt+C CSI should exit with ctrl+alt config") - } - // CSI u Ctrl+Alt+Shift+C (mod=7, includes Ctrl+Alt) - if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}) { - t.Fatalf("Ctrl+Alt+Shift+C should also exit") - } - // CSI u Ctrl+Shift+C (mod=5) - if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}) { - t.Fatalf("Ctrl+Shift+C should NOT exit with ctrl+alt config") - } - // CSI for other key - if isExitHotkeySeq([]byte{0x1b, '[', '9', '7', ';', '6', 'u'}) { - t.Fatalf("Ctrl+Alt+A should not exit") - } - - // Simple ESC c (Alt+C) should NOT exit — requires Ctrl modifier - if isExitHotkeySeq([]byte{0x1b, 'c'}) { - t.Fatalf("Alt+C (ESC c) should NOT exit — Ctrl modifier required") - } - - // Switch to ctrl+shift - *cfg = Config{HotkeyMod: "ctrl+shift"} - - if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '5', 'u'}) { - t.Fatalf("Ctrl+Shift+C should exit with ctrl+shift config") - } - if !isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '7', 'u'}) { - t.Fatalf("Ctrl+Shift+Alt+C should also exit (includes Ctrl+Shift)") - } - if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '6', 'u'}) { - t.Fatalf("Ctrl+Alt+C should NOT exit with ctrl+shift config") - } - // Simple ESC c should NOT exit with ctrl+shift - if isExitHotkeySeq([]byte{0x1b, 'c'}) { - t.Fatalf("ESC c should NOT exit with ctrl+shift config") - } - // Non-CSI garbage - if isExitHotkeySeq([]byte{0x1b, 'x'}) { - t.Fatalf("ESC x should not exit") - } - if isExitHotkeySeq([]byte("hello")) { - t.Fatalf("plain bytes should not exit") - } - - *cfg = Config{HotkeyMod: "ctrl+alt"} - // Ctrl only (mod=4) should not exit (requires Alt too) - if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '4', 'u'}) { - t.Fatalf("Ctrl+C (without Alt) should not exit") - } - // Alt only (mod=2) should not exit (requires Ctrl too) - if isExitHotkeySeq([]byte{0x1b, '[', '9', '9', ';', '2', 'u'}) { - t.Fatalf("Alt+C (without Ctrl) should not exit") - } -} diff --git a/internal/termapp/main_windows.go b/internal/termapp/main_windows.go deleted file mode 100644 index 4f43c45..0000000 --- a/internal/termapp/main_windows.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build windows - -package termapp - -import ( - "golang.org/x/sys/windows" -) - -func enableVTInput(fd int) { - var mode uint32 - if err := windows.GetConsoleMode(windows.Handle(fd), &mode); err == nil { - _ = windows.SetConsoleMode(windows.Handle(fd), mode|windows.ENABLE_VIRTUAL_TERMINAL_INPUT) - } -} diff --git a/internal/termapp/mutual.go b/internal/termapp/mutual.go deleted file mode 100644 index 82205ae..0000000 --- a/internal/termapp/mutual.go +++ /dev/null @@ -1,13 +0,0 @@ -package termapp - -import ( - "io" - "os" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session" -) - -var ( - sess *session.SerialSession - out io.Writer = os.Stdout -) diff --git a/internal/termapp/tui_test.go b/internal/termapp/tui_test.go deleted file mode 100644 index 4cea772..0000000 --- a/internal/termapp/tui_test.go +++ /dev/null @@ -1,309 +0,0 @@ -package termapp - -import ( - "strings" - "testing" - - "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" -) - -func TestParseCtrlKey(t *testing.T) { - tests := []struct { - in string - want byte - ok bool - reason string - }{ - {in: "ctrl+c", want: 'c', ok: true, reason: "plain ctrl"}, - {in: "ctrl+shift+c", ok: false, reason: "ctrl+shift reserved for local"}, - {in: "ctrl+enter", ok: false, reason: "non-letter"}, - {in: "alt+c", ok: false, reason: "wrong modifier"}, - } - - for _, tt := range tests { - got, ok := parseCtrlKey(tt.in) - if ok != tt.ok || got != tt.want { - t.Fatalf("%s parseCtrlKey(%q) got=(%q,%v) want=(%q,%v)", tt.reason, tt.in, got, ok, tt.want, tt.ok) - } - } -} - -func TestRenderModal(t *testing.T) { - modal := renderModal("Title", "line1\nline2", 80) - if !strings.Contains(modal, "Title") { - t.Fatalf("renderModal missing title: %q", modal) - } - if !strings.Contains(modal, "line1") || !strings.Contains(modal, "line2") { - t.Fatalf("renderModal missing lines: %q", modal) - } - if !strings.Contains(modal, "╭") || !strings.Contains(modal, "╮") || !strings.Contains(modal, "╰") || !strings.Contains(modal, "╯") { - t.Fatalf("renderModal missing box borders: %q", modal) - } -} - -func TestHandleCtrlShiftLocalHelp(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}} - a.SetUIEnabled(true) - m := uiModel{app: a} - - ok := handleLocalHotkey(&m, "ctrl+alt+h") - if !ok { - t.Fatalf("expected local hotkey to be handled") - } - - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventModal { - t.Fatalf("expected modal event, got %+v", ev) - } -} - -func TestNormalizeHotkeyPrefix(t *testing.T) { - tests := []struct { - in, want string - }{ - {"", "ctrl+alt"}, - {"ctrl+alt", "ctrl+alt"}, - {"ctrl+shift", "ctrl+shift"}, - {"CTRL+ALT", "ctrl+alt"}, - {" ctrl+SHIFT ", "ctrl+shift"}, - {"invalid", "ctrl+alt"}, - } - - for _, tt := range tests { - got := normalizeHotkeyPrefix(tt.in) - if got != tt.want { - t.Fatalf("normalizeHotkeyPrefix(%q) got=%q want=%q", tt.in, got, tt.want) - } - } -} - -func TestHotkeyWith(t *testing.T) { - got := hotkeyWith("ctrl+alt", "h") - if got != "ctrl+alt+h" { - t.Fatalf("hotkeyWith ctrl+alt+h got=%q", got) - } - got = hotkeyWith("ctrl+shift", "c") - if got != "ctrl+shift+c" { - t.Fatalf("hotkeyWith ctrl+shift+c got=%q", got) - } -} - -func TestIsLocalHotkeyAll(t *testing.T) { - tests := []struct { - key, mod string - action string - want bool - }{ - {"ctrl+alt+c", "ctrl+alt", "c", true}, - {"ctrl+shift+c", "ctrl+shift", "c", true}, - {"ctrl+alt+c", "ctrl+shift", "c", false}, - {"ctrl+shift+c", "ctrl+alt", "c", false}, - {"alt+c", "ctrl+alt", "c", false}, - {"ctrl+c", "ctrl+alt", "c", false}, - } - - for _, tt := range tests { - a := &App{cfg: &Config{HotkeyMod: tt.mod}} - m := uiModel{app: a} - got := m.isLocalHotkey(tt.key, tt.action) - if got != tt.want { - t.Fatalf("isLocalHotkey(%q, %q) hotkeyMod=%q got=%v want=%v", tt.key, tt.action, tt.mod, got, tt.want) - } - } -} - -func TestParseCtrlKeyEdgeCases(t *testing.T) { - tests := []struct { - in string - want byte - ok bool - }{ - {in: "ctrl+z", want: 'z', ok: true}, - {in: "ctrl+a", want: 'a', ok: true}, - {in: "ctrl+shift+c", want: 0, ok: false}, - {in: "ctrl+alt+c", want: 0, ok: false}, - {in: "ctrl+", want: 0, ok: false}, - {in: "ctrl+ab", want: 0, ok: false}, - {in: "ctrl+A", want: 0, ok: false}, - {in: "ctrl+1", want: 0, ok: false}, - } - - for _, tt := range tests { - got, ok := parseCtrlKey(tt.in) - if ok != tt.ok || got != tt.want { - t.Fatalf("parseCtrlKey(%q) got=(%q,%v) want=(%q,%v)", tt.in, got, ok, tt.want, tt.ok) - } - } -} - -func TestRenderModalLongContent(t *testing.T) { - longBody := "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8\nline9\nline10\nline11\nline12\nline13\nline14" - modal := renderModal("Title", longBody, 80) - if !strings.Contains(modal, "... (press Esc/Enter to close)") { - t.Fatalf("long modal should be truncated: %q", modal) - } - if strings.Contains(modal, "line14") { - t.Fatalf("line14 should not appear in truncated modal") - } -} - -func TestRenderModalEmpty(t *testing.T) { - modal := renderModal("", "", 80) - if !strings.Contains(modal, "Info") { - t.Fatalf("empty title should default to Info: %q", modal) - } -} - -func TestTruncateToWidth(t *testing.T) { - tests := []struct { - in string - width int - want string - }{ - {"hello", 3, "hel"}, - {"hello", 10, "hello"}, - {"", 5, ""}, - {"hello", 0, "hello"}, - } - - for _, tt := range tests { - got := truncateToWidth(tt.in, tt.width) - if got != tt.want { - t.Fatalf("truncateToWidth(%q, %d) got=%q want=%q", tt.in, tt.width, got, tt.want) - } - } -} - -func TestClampIndex(t *testing.T) { - tests := []struct { - idx, n int - want int - }{ - {2, 5, 2}, - {-1, 5, 0}, - {10, 5, 4}, - {0, 0, 0}, - {0, 1, 0}, - } - - for _, tt := range tests { - got := clampIndex(tt.idx, tt.n) - if got != tt.want { - t.Fatalf("clampIndex(%d, %d) got=%d want=%d", tt.idx, tt.n, got, tt.want) - } - } -} - -func TestMinInt(t *testing.T) { - if got := minInt(1, 2); got != 1 { - t.Fatalf("minInt(1,2) got=%d", got) - } - if got := minInt(5, 3); got != 3 { - t.Fatalf("minInt(5,3) got=%d", got) - } - if got := minInt(0, 0); got != 0 { - t.Fatalf("minInt(0,0) got=%d", got) - } -} - -func TestMaxIntFunc(t *testing.T) { - if got := maxInt(1, 2); got != 2 { - t.Fatalf("maxInt(1,2) got=%d", got) - } - if got := maxInt(5, 3, 7); got != 7 { - t.Fatalf("maxInt(5,3,7) got=%d", got) - } -} - -func TestHandleLocalHotkeyForward(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}} - a.SetUIEnabled(true) - m := uiModel{app: a} - - if !handleLocalHotkey(&m, "ctrl+alt+f") { - t.Fatalf("expected forward hotkey handled") - } - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelForward { - t.Fatalf("expected forward panel, got %+v", ev) - } -} - -func TestHandleLocalHotkeyPlugin(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}} - a.SetUIEnabled(true) - m := uiModel{app: a} - - if !handleLocalHotkey(&m, "ctrl+alt+p") { - t.Fatalf("expected plugin hotkey handled") - } - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelPlugin { - t.Fatalf("expected plugin panel, got %+v", ev) - } -} - -func TestHandleLocalHotkeyMode(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+alt"}} - a.SetUIEnabled(true) - m := uiModel{app: a} - - if !handleLocalHotkey(&m, "ctrl+alt+m") { - t.Fatalf("expected mode hotkey handled") - } - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventPanel || ev.Panel != event.UIPanelMode { - t.Fatalf("expected mode panel, got %+v", ev) - } -} - -func TestHandleLocalHotkeyUnknown(t *testing.T) { - a := &App{cfg: &Config{HotkeyMod: "ctrl+alt"}} - m := uiModel{app: a} - - if handleLocalHotkey(&m, "ctrl+alt+x") { - t.Fatalf("unknown hotkey should not be handled") - } -} - -func TestHandleLocalHotkeyCtrlShift(t *testing.T) { - a := &App{uiEvents: make(chan event.UIEvent, 4), cfg: &Config{HotkeyMod: "ctrl+shift"}} - a.SetUIEnabled(true) - m := uiModel{app: a} - - if !handleLocalHotkey(&m, "ctrl+shift+h") { - t.Fatalf("expected ctrl+shift+h to be handled") - } - ev := mustReadEvent(t, a.uiEvents) - if ev.Kind != event.UIEventModal { - t.Fatalf("expected help modal with ctrl+shift+h") - } -} - -func TestRenderPanelModal(t *testing.T) { - lines := []panelLine{ - {text: "Header", selected: false}, - {text: "Selected Row", selected: true}, - } - out := renderPanelModal("Test Panel", lines, "Footer text", 80) - if !strings.Contains(out, "Test Panel") { - t.Fatalf("missing title: %q", out) - } - if !strings.Contains(out, "Header") { - t.Fatalf("missing header line: %q", out) - } - if !strings.Contains(out, "Selected Row") { - t.Fatalf("missing selected line: %q", out) - } - if !strings.Contains(out, "Footer text") { - t.Fatalf("missing footer: %q", out) - } -} - -func TestStyleFunctions(t *testing.T) { - _ = modalFooterLineStyle() - rendered := selectedPanelLineStyle().Render("test") - if !strings.Contains(rendered, "test") { - t.Fatalf("selectedPanelLineStyle should render text") - } -} diff --git a/internal/termapp/tui_hotkeys.go b/internal/tui/hotkeys.go similarity index 82% rename from internal/termapp/tui_hotkeys.go rename to internal/tui/hotkeys.go index 5a480c6..7ba633c 100644 --- a/internal/termapp/tui_hotkeys.go +++ b/internal/tui/hotkeys.go @@ -1,4 +1,4 @@ -package termapp +package tui import ( "strings" @@ -7,28 +7,28 @@ import ( "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" ) -func handleLocalHotkey(m *uiModel, key string) bool { +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") + 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) + m.App.OpenPanel(event.UIPanelForward) return true } if m.isLocalHotkey(key, "p") { - m.app.OpenPanel(event.UIPanelPlugin) + m.App.OpenPanel(event.UIPanelPlugin) return true } if m.isLocalHotkey(key, "m") { - m.app.OpenPanel(event.UIPanelMode) + m.App.OpenPanel(event.UIPanelMode) return true } return false } -func (m *uiModel) isLocalHotkey(key, action string) bool { +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 @@ -48,7 +48,7 @@ func (m *uiModel) isLocalHotkey(key, action string) bool { } } - mod := normalizeHotkeyPrefix(m.app.cfg.HotkeyMod) + mod := normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod) if mod == "ctrl+shift" { return hasCtrl && hasShift } @@ -83,7 +83,7 @@ func parseCtrlKey(key string) (byte, bool) { return ch, true } -func (m *uiModel) handleViewportKey(msg tea.KeyMsg) bool { +func (m *Model) handleViewportKey(msg tea.KeyMsg) bool { if !m.ready || m.showModal { return false } @@ -114,14 +114,14 @@ func (m *uiModel) handleViewportKey(msg tea.KeyMsg) bool { } } -func (m *uiModel) resetCompletion() { +func (m *Model) resetCompletion() { m.completionActive = false m.completionBase = "" m.completionCandidates = nil m.completionIndex = 0 } -func (m *uiModel) stepCompletion(direction int) { +func (m *Model) stepCompletion(direction int) { if len(m.completionCandidates) == 0 { m.resetCompletion() return @@ -134,7 +134,7 @@ func (m *uiModel) stepCompletion(direction int) { m.applyCompletion() } -func (m *uiModel) applyCompletion() { +func (m *Model) applyCompletion() { if len(m.completionCandidates) == 0 { return } diff --git a/internal/termapp/tui_model.go b/internal/tui/model.go similarity index 81% rename from internal/termapp/tui_model.go rename to internal/tui/model.go index feee8fe..1abf503 100644 --- a/internal/termapp/tui_model.go +++ b/internal/tui/model.go @@ -1,4 +1,4 @@ -package termapp +package tui import ( "fmt" @@ -8,6 +8,8 @@ import ( "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" @@ -27,8 +29,8 @@ type panelLine struct { selected bool } -type uiModel struct { - app *App +type Model struct { + App *app.App viewport viewport.Model input textinput.Model @@ -47,6 +49,7 @@ type uiModel struct { panelKind event.UIPanelKind panelIndex int + panelError string forwardItems []forward.Snapshot pluginItems []luaplugin.Snapshot @@ -64,21 +67,19 @@ type uiModel struct { completionIndex int } -func newUIModel(app *App) *uiModel { +func New(application *app.App) *Model { 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} + return &Model{App: application, input: in, followTail: true} } -func (m *uiModel) Init() tea.Cmd { - return tea.Batch(waitUIEvent(m.app.uiEvents), waitDone(m.app.waitDone()), textinput.Blink) +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 { @@ -98,7 +99,7 @@ func waitDone(ch <-chan struct{}) tea.Cmd { } } -func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case doneMsg: return m, tea.Quit @@ -120,7 +121,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case event.UIEventPanel: m.openPanel(msg.Panel) } - return m, waitUIEvent(m.app.uiEvents) + return m, waitUIEvent(m.App.UIEvents()) case tea.WindowSizeMsg: m.width = msg.Width @@ -156,13 +157,16 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.resetCompletion() } - if m.showModal && m.handleModalKey(msg) { - return m, nil + 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() + m.App.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.App.Cfg().HotkeyMod))) + m.App.Close() return m, tea.Quit } @@ -170,22 +174,21 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { 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")) + 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) + 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")) + handleLocalHotkey(m, hotkeyWith(m.App.Cfg().HotkeyMod, "h")) return m, nil case "tab", "shift+tab": @@ -199,7 +202,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - line, cands := m.app.dispatcher.Complete(m.input.Value()) + line, cands := m.App.Dispatcher().Complete(m.input.Value()) m.suggestions = cands if len(cands) == 0 { return m, nil @@ -225,7 +228,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.input.SetValue("") m.suggestions = nil m.followTail = true - m.app.handleLine(line) + m.App.HandleLine(line) return m, nil } } @@ -235,7 +238,7 @@ func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd } -func (m *uiModel) View() string { +func (m *Model) View() string { if !m.ready { return "Initializing..." } @@ -246,7 +249,7 @@ func (m *uiModel) View() string { } else if len(m.suggestions) == 1 { suggest = "Tab: " + m.suggestions[0] } - modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod)) + 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 diff --git a/internal/termapp/tui_panels.go b/internal/tui/panels.go similarity index 52% rename from internal/termapp/tui_panels.go rename to internal/tui/panels.go index 68ff218..7655df5 100644 --- a/internal/termapp/tui_panels.go +++ b/internal/tui/panels.go @@ -1,4 +1,4 @@ -package termapp +package tui import ( "fmt" @@ -6,10 +6,12 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" + "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event" + "github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward" ) -func (m *uiModel) handleModalKey(msg tea.KeyMsg) bool { +func (m *Model) handleModalKey(msg tea.KeyMsg) (bool, tea.Cmd) { keyStr := strings.ToLower(msg.String()) if m.promptActive { @@ -17,52 +19,54 @@ func (m *uiModel) handleModalKey(msg tea.KeyMsg) bool { } if keyStr == "esc" { m.closeModal() - return true + return true, nil } if m.panelKind == event.UIPanelNone { if keyStr == "enter" { m.closeModal() } - return true + return true, nil } switch m.panelKind { case event.UIPanelForward: - return m.handleForwardPanelKey(keyStr) + return m.handleForwardPanelKey(keyStr), nil case event.UIPanelPlugin: - return m.handlePluginPanelKey(keyStr) + return m.handlePluginPanelKey(keyStr), nil case event.UIPanelMode: - return m.handleModePanelKey(keyStr) + return m.handleModePanelKey(keyStr), nil default: - return true + return true, nil } } -func (m *uiModel) closeModal() { +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 *uiModel) openPanel(kind event.UIPanelKind) { +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 *uiModel) refreshPanel() { +func (m *Model) refreshPanel() { switch m.panelKind { case event.UIPanelForward: - m.forwardItems = m.app.forward.List() + m.forwardItems = m.App.Forward().List() m.panelIndex = clampIndex(m.panelIndex, len(m.forwardItems)) case event.UIPanelPlugin: - m.pluginItems = m.app.plugins.List() + m.pluginItems = m.App.Plugins().List() m.panelIndex = clampIndex(m.panelIndex, len(m.pluginItems)) case event.UIPanelMode: m.modeItems = m.buildModeItems() @@ -70,18 +74,19 @@ func (m *uiModel) refreshPanel() { } } -func (m *uiModel) buildModeItems() []modeItem { +func (m *Model) buildModeItems() []modeItem { + cfg := m.App.Cfg() return []modeItem{ - {"in", "Input Charset", m.app.cfg.InputCode, m.app.cfg.InputCode}, - {"out", "Output Charset", m.app.cfg.OutputCode, m.app.cfg.OutputCode}, - {"end", "Line End", fmt.Sprintf("%q", m.app.cfg.EndStr), m.app.cfg.EndStr}, - {"frame", "Hex Frame Size", fmt.Sprintf("%d", m.app.cfg.FrameSize), fmt.Sprintf("%d", m.app.cfg.FrameSize)}, - {"timestamp", "Timestamp", fmt.Sprintf("%v", m.app.cfg.TimesTamp), fmt.Sprintf("%v", m.app.cfg.TimesTamp)}, - {"timefmt", "Timestamp Format", m.app.cfg.TimesFmt, m.app.cfg.TimesFmt}, + {"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 *uiModel) handleForwardPanelKey(key string) bool { +func (m *Model) handleForwardPanelKey(key string) bool { switch key { case "up", "k": if m.panelIndex > 0 { @@ -94,17 +99,27 @@ func (m *uiModel) handleForwardPanelKey(key string) bool { } 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.app.Statusf("[forward] usage:
") + m.panelError = "usage:
" return } - m.app.handleLine(fmt.Sprintf(".forward add %s %s", parts[0], parts[1])) - m.refreshPanel() + 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 } @@ -116,25 +131,47 @@ func (m *uiModel) handleForwardPanelKey(key string) bool { switch key { case "enter": if sel.Enabled { - m.app.handleLine(fmt.Sprintf(".forward disable %d", sel.ID)) + if err := m.App.Forward().Disable(sel.ID); err != nil { + m.panelError = err.Error() + } } else { - m.app.handleLine(fmt.Sprintf(".forward enable %d", sel.ID)) + if err := m.App.Forward().Enable(sel.ID); err != nil { + m.panelError = err.Error() + } } + m.panelError = "" m.refreshPanel() return true - case "d", "delete", "backspace": - m.app.handleLine(fmt.Sprintf(".forward remove %d", sel.ID)) - m.refreshPanel() + 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.app.Statusf("[forward] usage:
") + m.panelError = "usage:
" return } - m.app.handleLine(fmt.Sprintf(".forward update %d %s %s", sel.ID, parts[0], parts[1])) - m.refreshPanel() + 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: @@ -142,7 +179,7 @@ func (m *uiModel) handleForwardPanelKey(key string) bool { } } -func (m *uiModel) handlePluginPanelKey(key string) bool { +func (m *Model) handlePluginPanelKey(key string) bool { switch key { case "up", "k": if m.panelIndex > 0 { @@ -155,17 +192,22 @@ func (m *uiModel) handlePluginPanelKey(key string) bool { } 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.app.Statusf("[plugin] load path is empty") + m.panelError = "load path is empty" return } - m.app.handleLine(fmt.Sprintf(".plugin load %s", path)) - m.refreshPanel() + if _, err := m.App.Plugins().Load(path); err != nil { + m.panelError = err.Error() + } else { + m.panelError = "" + m.refreshPanel() + } }) return true } @@ -177,26 +219,39 @@ func (m *uiModel) handlePluginPanelKey(key string) bool { switch key { case "enter": if sel.Enabled { - m.app.handleLine(fmt.Sprintf(".plugin disable %s", sel.Name)) + _ = m.App.Plugins().Disable(sel.Name) } else { - m.app.handleLine(fmt.Sprintf(".plugin enable %s", sel.Name)) + _ = m.App.Plugins().Enable(sel.Name) } + m.panelError = "" m.refreshPanel() return true case "u": - m.app.handleLine(fmt.Sprintf(".plugin reload %s", sel.Name)) - m.refreshPanel() + if err := m.App.Plugins().Reload(sel.Name); err != nil { + m.panelError = err.Error() + } else { + m.panelError = "" + m.refreshPanel() + } return true - case "d", "delete", "backspace": - m.app.handleLine(fmt.Sprintf(".plugin unload %s", sel.Name)) - m.refreshPanel() + 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 *uiModel) handleModePanelKey(key string) bool { +func (m *Model) handleModePanelKey(key string) bool { switch key { case "up", "k": if m.panelIndex > 0 { @@ -209,6 +264,7 @@ func (m *uiModel) handleModePanelKey(key string) bool { } return true case "r": + m.panelError = "" m.refreshPanel() return true } @@ -217,21 +273,27 @@ func (m *uiModel) handleModePanelKey(key string) bool { } sel := m.modeItems[m.panelIndex] + cfg := m.App.Cfg() switch key { case " ": if sel.key == "timestamp" { - if m.app.cfg.TimesTamp { - m.app.handleLine(".mode set timestamp off") - } else { - m.app.handleLine(".mode set timestamp on") - } + 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, "new value", initial, func(v string) { - m.app.handleLine(fmt.Sprintf(".mode set %s %s", sel.key, v)) + 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 @@ -240,7 +302,7 @@ func (m *uiModel) handleModePanelKey(key string) bool { } } -func (m *uiModel) startPrompt(title, hint, initial string, submit func(string)) { +func (m *Model) startPrompt(title, hint, initial string, submit func(string)) { in := textinput.New() in.Prompt = "> " in.Placeholder = hint @@ -256,13 +318,13 @@ func (m *uiModel) startPrompt(title, hint, initial string, submit func(string)) m.promptSubmit = submit } -func (m *uiModel) handlePromptKey(msg tea.KeyMsg) bool { +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 + return true, nil case "enter": value := strings.TrimSpace(m.promptInput.Value()) submit := m.promptSubmit @@ -271,16 +333,15 @@ func (m *uiModel) handlePromptKey(msg tea.KeyMsg) bool { if submit != nil { submit(value) } - return true + return true, nil default: var cmd tea.Cmd m.promptInput, cmd = m.promptInput.Update(msg) - _ = cmd - return true + return true, cmd } } -func (m *uiModel) renderPanel() string { +func (m *Model) renderPanel() string { switch m.panelKind { case event.UIPanelForward: return m.renderForwardPanel() @@ -293,21 +354,24 @@ func (m *uiModel) renderPanel() string { } } -func (m *uiModel) renderForwardPanel() string { - lines := make([]panelLine, 0, len(m.forwardItems)+2) +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 InBytes OutBytes"}) + 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 %-22s %-7d %-8d", it.ID, it.Mode, it.Enabled, it.Connected, it.Address, it.ReadBytes, it.WriteByte), selected: i == m.panelIndex}) + 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}) } } - return renderPanelModal("Forward Panel", lines, "Up/Down select | Enter toggle enable | a add | u update | d remove | r refresh | Esc close", m.availableModalWidth()) + 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 *uiModel) renderPluginPanel() string { - lines := make([]panelLine, 0, len(m.pluginItems)+2) +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 { @@ -316,14 +380,21 @@ func (m *uiModel) renderPluginPanel() string { lines = append(lines, panelLine{text: fmt.Sprintf("%-20s %-7v %s", it.Name, it.Enabled, it.Path), selected: i == m.panelIndex}) } } - return renderPanelModal("Plugin Panel", lines, "Up/Down select | Enter toggle enable | l load | u reload | d unload | r refresh | Esc close", m.availableModalWidth()) + 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 *uiModel) renderModePanel() string { - lines := make([]panelLine, 0, len(m.modeItems)+2) +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}) } - return renderPanelModal("Mode Panel", lines, "Up/Down select | Enter edit value | Space toggle timestamp | r refresh | Esc close", m.availableModalWidth()) + 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()) } + diff --git a/internal/termapp/tui_render.go b/internal/tui/render.go similarity index 82% rename from internal/termapp/tui_render.go rename to internal/tui/render.go index ad6bf04..53b6311 100644 --- a/internal/termapp/tui_render.go +++ b/internal/tui/render.go @@ -1,4 +1,4 @@ -package termapp +package tui import ( "strings" @@ -6,7 +6,7 @@ import ( "github.com/charmbracelet/lipgloss" ) -func (m *uiModel) appendOutput(text string) { +func (m *Model) appendOutput(text string) { if text == "" { return } @@ -19,7 +19,7 @@ func (m *uiModel) appendOutput(text string) { } } -func (m *uiModel) renderPrompt() string { +func (m *Model) renderPrompt() string { lines := []boxLine{ {text: m.promptHint, style: modalBodyLineStyle()}, {text: m.promptInput.View(), style: modalBodyLineStyle()}, @@ -80,37 +80,13 @@ func renderCenteredModalContent(width, height int, content string) string { if width <= 0 || height <= 0 { return content } - - lines := strings.Split(content, "\n") - blockWidth := 0 - for _, line := range lines { - blockWidth = maxInt(blockWidth, lipgloss.Width(line)) - } - blockHeight := len(lines) - leftPad := 0 - if width > blockWidth { - leftPad = (width - blockWidth) / 2 - } - topPad := 0 - if height > blockHeight { - topPad = (height - blockHeight) / 2 - } - - var b strings.Builder - for i := 0; i < topPad; i++ { - b.WriteByte('\n') - } - for i, line := range lines { - if i > 0 { - b.WriteByte('\n') - } - b.WriteString(strings.Repeat(" ", leftPad)) - b.WriteString(line) - } - return b.String() + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content, + lipgloss.WithWhitespaceChars(" "), + lipgloss.WithWhitespaceForeground(lipgloss.Color("0")), + ) } -func (m *uiModel) availableModalWidth() int { +func (m *Model) availableModalWidth() int { if m.width <= 0 { return 100 } @@ -134,8 +110,9 @@ func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string { contentWidth = maxInt(minWidth, contentWidth) contentWidth = minInt(contentWidth, maxWidth) - top := "╭" + strings.Repeat("─", contentWidth+2) + "╮" - bottom := "╰" + strings.Repeat("─", contentWidth+2) + "╯" + 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) @@ -150,8 +127,8 @@ func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string { 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 "│ " + inner + " │" + inner := contentStyle.Render(" " + visible + pad + " ") + return contentStyle.Render("│" + inner + "│") } func modalHeaderLineStyle() lipgloss.Style { @@ -170,7 +147,6 @@ 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