refactor: move all app code to internal/termapp and entry to cmd/

Consolidate remaining package main files into internal/termapp for
clean separation: cmd/serialterminal/ is the sole entry point.
Update .goreleaser.yaml to build from ./cmd/serialterminal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
JiXieShi
2026-05-23 21:56:45 +08:00
parent 65c1a48f10
commit 8139162174
18 changed files with 26 additions and 18 deletions
+391
View File
@@ -0,0 +1,391 @@
package termapp
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
appconfig "github.com/jixishi/SerialTerminalForWindowsTerminal/internal/config"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/charset"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
)
type App struct {
cfg *Config
forward *forward.Manager
plugins *luaplugin.Manager
dispatcher *CommandDispatcher
uiEvents chan event.UIEvent
done chan struct{}
stdinMu sync.Mutex
closeOnce sync.Once
closedFlag atomic.Bool
uiEnabled atomic.Bool
logFile *os.File
}
func NewApp(cfg *Config) (*App, error) {
f, err := appconfig.OpenLogFile(cfg)
if err != nil {
return nil, err
}
a := &App{
cfg: cfg,
plugins: luaplugin.NewManager(),
uiEvents: make(chan event.UIEvent, 512),
done: make(chan struct{}),
logFile: f,
}
a.uiEnabled.Store(true)
a.forward = forward.NewManager(a.writeRawToSession, a.Notifyf)
a.forward.SetInboundReporter(a.reportForwardIngress)
a.dispatcher = NewCommandDispatcher(a)
if err = a.loadDefaultDemoPlugin(); err != nil {
return nil, err
}
return a, nil
}
func (a *App) loadDefaultDemoPlugin() error {
demoPath := filepath.Join("plugins", "demo.lua")
if _, err := os.Stat(demoPath); err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
name, err := a.plugins.Load(demoPath)
if err != nil {
return err
}
return a.plugins.Disable(name)
}
func (a *App) Notifyf(format string, args ...any) {
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: fmt.Sprintf(format, args...)})
}
func (a *App) Statusf(format string, args ...any) {
a.emit(event.UIEvent{Kind: event.UIEventStatus, Text: fmt.Sprintf(format, args...)})
}
func (a *App) ShowModal(title, text string) {
a.emit(event.UIEvent{Kind: event.UIEventModal, Title: title, Text: text})
}
func (a *App) OpenPanel(panel event.UIPanelKind) {
a.emit(event.UIEvent{Kind: event.UIEventPanel, Panel: panel})
}
func (a *App) SetUIEnabled(enabled bool) {
a.uiEnabled.Store(enabled)
}
func (a *App) UIEnabled() bool {
return a.uiEnabled.Load()
}
func (a *App) emit(ev event.UIEvent) {
if ev.Kind != event.UIEventPanel && ev.Text == "" {
return
}
if !a.UIEnabled() {
switch ev.Kind {
case event.UIEventOutput:
_, _ = io.WriteString(out, ev.Text)
case event.UIEventStatus:
_, _ = io.WriteString(out, ev.Text)
if !strings.HasSuffix(ev.Text, "\n") {
_, _ = io.WriteString(out, "\n")
}
case event.UIEventModal:
_, _ = io.WriteString(out, "\n["+ev.Title+"]\n"+ev.Text+"\n")
}
if ev.Kind == event.UIEventOutput {
a.appendLog(ev.Text)
}
return
}
select {
case a.uiEvents <- ev:
default:
// Keep UI responsive; drop oldest when overloaded.
select {
case <-a.uiEvents:
default:
}
a.uiEvents <- ev
}
if ev.Kind == event.UIEventOutput {
a.appendLog(ev.Text)
}
}
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()
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)
if m == forward.None {
continue
}
if i >= len(a.cfg.Address) {
a.Notifyf("[forward] skip #%d: missing address", i)
continue
}
addr := strings.TrimSpace(a.cfg.Address[i])
if addr == "" {
continue
}
if _, err := a.forward.Add(m, addr); err != nil {
a.Notifyf("[forward] add %s %s failed: %v", m.String(), addr, err)
}
}
}
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)
}
text := string(converted)
if !strings.HasSuffix(text, "\n") {
text += "\n"
}
a.Notifyf("[forward#%d -> serial] %s", id, text)
}
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)
return err
}
func (a *App) writeToSession(data []byte) error {
processed, err := a.plugins.ProcessInput(data)
if err != nil {
return err
}
if len(processed) == 0 {
return nil
}
return a.writeRawToSession(processed)
}
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)
}
func (a *App) sendCtrl(letter byte) error {
if letter >= 'A' && letter <= 'Z' {
letter = letter + ('a' - 'A')
}
control := []byte{letter & 0x1f}
_, err := sess.Port.Write(control)
return err
}
func (a *App) handleLine(line string) {
line = strings.TrimRight(line, "\r\n")
if strings.TrimSpace(line) == "" {
return
}
if strings.HasPrefix(strings.TrimSpace(line), ".") {
next, allow, err := a.plugins.ProcessCommand(line)
if err != nil {
a.Notifyf("[plugin] command hook failed: %v", err)
return
}
if !allow {
a.Notifyf("[plugin] command blocked")
return
}
if next != "" {
line = next
}
handled, err := a.dispatcher.Execute(line)
if err != nil {
a.Statusf("[cmd] %v", err)
}
if handled {
return
}
}
if err := a.sendLine(line); err != nil {
a.Statusf("[send] %v", err)
}
}
func (a *App) startOutputLoop() {
if strings.EqualFold(a.cfg.InputCode, "hex") {
go a.readHexOutput()
return
}
go a.readTextOutput()
}
func (a *App) readHexOutput() {
frameSize := a.cfg.FrameSize
if frameSize <= 0 {
frameSize = 16
}
buf := make([]byte, frameSize)
for {
n, err := sess.StdoutPipe.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
a.forward.Broadcast(chunk)
outChunk, hookErr := a.plugins.ProcessOutput(chunk)
if hookErr != nil {
a.Notifyf("[plugin] output hook failed: %v", hookErr)
continue
}
if len(outChunk) == 0 {
continue
}
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: charset.FormatHexFrame(outChunk, a.cfg.TimesTamp, a.cfg.TimesFmt)})
}
if err != nil {
if err != io.EOF {
a.Notifyf("[output] %v", err)
}
return
}
select {
case <-a.done:
return
default:
}
}
}
func (a *App) readTextOutput() {
buf := make([]byte, 4096)
for {
n, err := sess.StdoutPipe.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
a.forward.Broadcast(chunk)
outChunk, hookErr := a.plugins.ProcessOutput(chunk)
if hookErr != nil {
a.Notifyf("[plugin] output hook failed: %v", hookErr)
continue
}
if len(outChunk) == 0 {
continue
}
converted, convErr := charset.ConvertChunk(outChunk, a.cfg.InputCode, a.cfg.OutputCode)
if convErr != nil {
a.Notifyf("[output] convert failed: %v", convErr)
converted = bytes.Clone(outChunk)
}
text := string(converted)
if a.cfg.TimesTamp {
text = prefixLines(text, time.Now().Format(a.cfg.TimesFmt)+" ")
}
a.emit(event.UIEvent{Kind: event.UIEventOutput, Text: text})
}
if err != nil {
if err != io.EOF {
a.Notifyf("[output] %v", err)
}
return
}
select {
case <-a.done:
return
default:
}
}
}
func prefixLines(s, prefix string) string {
if s == "" || prefix == "" {
return s
}
lines := strings.SplitAfter(s, "\n")
for i, line := range lines {
if line == "" {
continue
}
lines[i] = prefix + line
}
return strings.Join(lines, "")
}
+259
View File
@@ -0,0 +1,259 @@
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{}
}
}
+478
View File
@@ -0,0 +1,478 @@
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 <hex-data>",
Description: "send raw hex bytes",
Handler: func(args []string) error {
if len(args) < 2 {
return fmt.Errorf("usage: .hex <hex-data>")
}
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 <list|add|remove|enable|disable|update|stats>",
Description: "manage forwarding at runtime",
Handler: d.handleForwardCommand,
Completer: completeForward,
})
d.register(RuntimeCommand{
Name: ".plugin",
Usage: ".plugin <list|load|unload|enable|disable|reload>",
Description: "manage lua plugins",
Handler: d.handlePluginCommand,
Completer: completePlugin,
})
d.register(RuntimeCommand{
Name: ".mode",
Usage: ".mode <show|set>",
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 <tcp|udp> <address>")
}
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 <id>", 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> <tcp|udp> <address>")
}
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 <path>")
}
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 <name>", 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 <show|set>")
}
if len(args) < 4 {
return fmt.Errorf("usage: .mode set <in|out|end|frame|timestamp|timefmt> <value>")
}
field := strings.ToLower(args[2])
value := strings.Join(args[3:], " ")
switch field {
case "in":
d.app.cfg.InputCode = value
case "out":
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":
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
}
}
+502
View File
@@ -0,0 +1,502 @@
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)
}
}
+10
View File
@@ -0,0 +1,10 @@
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{}
+82
View File
@@ -0,0 +1,82 @@
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")
}
}
+123
View File
@@ -0,0 +1,123 @@
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")
}
}
+374
View File
@@ -0,0 +1,374 @@
package termapp
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/pflag"
"io"
"log"
"os"
"os/signal"
"strconv"
"strings"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/flag"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
"golang.org/x/term"
)
func init() {
log.SetFlags(log.Ldate | log.Ltime | log.Lshortfile | log.Lmsgprefix)
flag.Init(cfg)
}
func Run() {
defer func() {
if r := recover(); r != nil {
fmt.Fprintf(os.Stderr, "fatal: %v\n", r)
os.Exit(1)
}
}()
flag.Normalize()
pflag.Parse()
flag.Ext(cfg)
if cfg.PortName == "" {
flag.GetCliFlag(cfg)
}
ports, err := session.CheckPortAvailability(cfg.PortName)
if err != nil {
fmt.Println(err)
flag.PrintUsage(ports)
os.Exit(0)
}
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)
if err != nil {
fmt.Fprintf(os.Stderr, "create app failed: %v\n", err)
os.Exit(1)
}
defer app.Close()
app.loadConfiguredForwards()
app.startOutputLoop()
go forwardInterruptToRemote(app)
app.SetUIEnabled(cfg.EnableGUI)
if cfg.EnableGUI {
model := newUIModel(app)
p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithoutSignalHandler())
if _, err = p.Run(); err != nil {
fmt.Fprintf(os.Stderr, "tui failed: %v\n", err)
os.Exit(1)
}
return
}
if err = runConsole(app); err != nil {
fmt.Fprintf(os.Stderr, "console failed: %v\n", err)
os.Exit(1)
}
}
func forwardInterruptToRemote(app *App) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
defer signal.Stop(sigCh)
for {
select {
case <-app.waitDone():
return
case <-sigCh:
if err := app.sendCtrl('c'); err != nil {
app.Notifyf("[signal] interrupt pass-through failed: %v", err)
continue
}
app.Notifyf("[signal] Ctrl+C forwarded to remote")
}
}
}
func runConsole(app *App) error {
fd := int(os.Stdin.Fd())
isTerm := term.IsTerminal(fd)
var oldState *term.State
var err error
if isTerm {
enableVTInput(fd)
oldState, err = term.MakeRaw(fd)
if err != nil {
return err
}
defer func() {
_ = term.Restore(fd, oldState)
}()
}
app.Notifyf("[console] non-gui mode, commands start with '.' at line start\n")
app.Notifyf("[console] Ctrl+<Key> 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() {
buf := make([]byte, 256)
for {
n, rdErr := os.Stdin.Read(buf)
if rdErr != nil {
errCh <- rdErr
return
}
for i := 0; i < n; i++ {
ch <- buf[i]
}
}
}()
lineStart := true
commandMode := false
cmdBuf := make([]byte, 0, 128)
tryRead := func() (byte, bool) {
select {
case b := <-ch:
return b, true
default:
return 0, false
}
}
readByte := func() (byte, error) {
select {
case <-app.waitDone():
return 0, io.EOF
case rdErr := <-errCh:
return 0, rdErr
case b := <-ch:
return b, nil
}
}
// flushESC sends a fully-built escape sequence to serial.
flushESC := func(seq []byte) bool {
if isExitHotkeySeq(seq) {
app.Close()
return true
}
if err = app.writeToSession(seq); err != nil {
app.Statusf("[send] %v", err)
}
return false
}
for {
b, rdErr := readByte()
if rdErr != nil {
if rdErr == io.EOF {
return nil
}
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)
}
break
}
escBuf = append(escBuf, nb)
// CSI terminator byte (0x400x7E): AZ, az, ~, 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)
}
break
}
}
continue
}
// ── Windows Alt+key: NULL prefix ──
if b == 0x00 {
if b2, ok := tryRead(); ok {
if isAltKeyExit(b2) {
app.Close()
return nil
}
if err = app.writeToSession([]byte{0x00, b2}); err != nil {
app.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 commandMode {
lineStart = false
}
continue
}
// ── Command mode ──
if commandMode {
switch b {
case '\r', '\n':
echoConsoleNewline()
line := string(cmdBuf)
if strings.TrimSpace(line) != "" {
app.handleLine(line)
}
commandMode = false
cmdBuf = cmdBuf[:0]
lineStart = true
case 0x7f, 0x08:
if len(cmdBuf) > 0 {
cmdBuf = cmdBuf[:len(cmdBuf)-1]
echoConsoleBackspace()
}
case 0x09: // Tab — command completion
line, cands := app.dispatcher.Complete(string(cmdBuf))
if len(cands) == 1 {
cmdBuf = append(cmdBuf[:0], line...)
echoRedrawCommand(line)
} else if len(cands) > 1 {
echoConsoleNewline()
app.Notifyf("%s", strings.Join(cands, " "))
echoConsoleByte('.')
echoConsoleString(string(cmdBuf[1:]))
}
default:
cmdBuf = append(cmdBuf, b)
echoConsoleByte(b)
}
continue
}
// ── Normal mode (sending to remote) ──
if lineStart && b == '.' {
commandMode = true
cmdBuf = append(cmdBuf[:0], b)
echoConsoleByte(b)
continue
}
if b == '\r' || b == '\n' {
if err = app.writeToSession([]byte(cfg.EndStr)); err != nil {
app.Statusf("[send] %v", err)
}
lineStart = true
} else {
if err = app.writeToSession([]byte{b}); err != nil {
app.Statusf("[send] %v", err)
}
lineStart = false
}
}
}
func parseCSIu(seq []byte) (cp int, mod int, ok bool) {
// ESC [ codepoint ; modifier u
if len(seq) < 6 {
return 0, 0, false
}
if seq[0] != 0x1b || seq[1] != '[' {
return 0, 0, false
}
if seq[len(seq)-1] != 'u' {
return 0, 0, false
}
inner := string(seq[2 : len(seq)-1])
parts := strings.SplitN(inner, ";", 2)
if len(parts) != 2 {
return 0, 0, false
}
cp, err := strconv.Atoi(parts[0])
if err != nil {
return 0, 0, false
}
mod, err = strconv.Atoi(parts[1])
if err != nil {
return 0, 0, false
}
return cp, mod, true
}
func isAltKeyExit(b byte) bool {
if normalizeHotkeyPrefix(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.
if cp, cmod, ok := parseCSIu(seq); ok {
if cp != 'c' && cp != 'C' {
return false
}
switch mod {
case "ctrl+alt":
return cmod&6 == 6
case "ctrl+shift":
return cmod&5 == 5
}
return false
}
return false
}
func echoConsoleByte(b byte) {
_, _ = out.Write([]byte{b})
}
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)
}
+5
View File
@@ -0,0 +1,5 @@
//go:build !windows
package termapp
func enableVTInput(fd int) {}
+14
View File
@@ -0,0 +1,14 @@
//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)
}
}
+13
View File
@@ -0,0 +1,13 @@
package termapp
import (
"io"
"os"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/session"
)
var (
sess *session.SerialSession
out io.Writer = os.Stdout
)
+153
View File
@@ -0,0 +1,153 @@
package termapp
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
)
func handleLocalHotkey(m *uiModel, key string) bool {
if m.isLocalHotkey(key, "h") {
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod))
m.app.ShowModal("Shortcuts", modifier+"+C => local exit\nCtrl+C => remote interrupt\n"+modifier+"+F => forward panel\n"+modifier+"+P => plugin panel\n"+modifier+"+M => mode panel\nF1 => shortcut help")
return true
}
if m.isLocalHotkey(key, "f") {
m.app.OpenPanel(event.UIPanelForward)
return true
}
if m.isLocalHotkey(key, "p") {
m.app.OpenPanel(event.UIPanelPlugin)
return true
}
if m.isLocalHotkey(key, "m") {
m.app.OpenPanel(event.UIPanelMode)
return true
}
return false
}
func (m *uiModel) isLocalHotkey(key, action string) bool {
parts := strings.Split(strings.ToLower(key), "+")
if len(parts) < 2 || parts[len(parts)-1] != action {
return false
}
hasCtrl := false
hasAlt := false
hasShift := false
for _, p := range parts[:len(parts)-1] {
switch p {
case "ctrl":
hasCtrl = true
case "alt":
hasAlt = true
case "shift":
hasShift = true
}
}
mod := normalizeHotkeyPrefix(m.app.cfg.HotkeyMod)
if mod == "ctrl+shift" {
return hasCtrl && hasShift
}
return hasCtrl && hasAlt
}
func normalizeHotkeyPrefix(mod string) string {
mod = strings.ToLower(strings.TrimSpace(mod))
if mod != "ctrl+alt" && mod != "ctrl+shift" {
mod = "ctrl+alt"
}
return mod
}
func hotkeyWith(mod, action string) string {
return normalizeHotkeyPrefix(mod) + "+" + action
}
func parseCtrlKey(key string) (byte, bool) {
if !strings.HasPrefix(key, "ctrl+") || strings.HasPrefix(key, "ctrl+shift+") {
return 0, false
}
parts := strings.Split(key, "+")
if len(parts) != 2 || len(parts[1]) != 1 {
return 0, false
}
ch := parts[1][0]
if ch < 'a' || ch > 'z' {
return 0, false
}
return ch, true
}
func (m *uiModel) handleViewportKey(msg tea.KeyMsg) bool {
if !m.ready || m.showModal {
return false
}
key := strings.ToLower(msg.String())
switch key {
case "pgup", "ctrl+u", "alt+up", "up":
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
_ = cmd
m.followTail = false
return true
case "pgdown", "ctrl+d", "alt+down", "down":
var cmd tea.Cmd
m.viewport, cmd = m.viewport.Update(msg)
_ = cmd
return true
case "home", "g":
m.viewport.GotoTop()
m.followTail = false
return true
case "end", "shift+g":
m.viewport.GotoBottom()
m.followTail = true
return true
default:
return false
}
}
func (m *uiModel) resetCompletion() {
m.completionActive = false
m.completionBase = ""
m.completionCandidates = nil
m.completionIndex = 0
}
func (m *uiModel) stepCompletion(direction int) {
if len(m.completionCandidates) == 0 {
m.resetCompletion()
return
}
if direction >= 0 {
m.completionIndex = (m.completionIndex + 1) % len(m.completionCandidates)
} else {
m.completionIndex = (m.completionIndex - 1 + len(m.completionCandidates)) % len(m.completionCandidates)
}
m.applyCompletion()
}
func (m *uiModel) applyCompletion() {
if len(m.completionCandidates) == 0 {
return
}
m.input.SetValue(m.completionBase + m.completionCandidates[m.completionIndex] + " ")
}
func completionBase(line string) string {
if strings.HasSuffix(line, " ") {
return line
}
i := strings.LastIndex(line, " ")
if i < 0 {
return ""
}
return line[:i+1]
}
+270
View File
@@ -0,0 +1,270 @@
package termapp
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/forward"
"github.com/jixishi/SerialTerminalForWindowsTerminal/pkg/luaplugin"
)
type doneMsg struct{}
type modeItem struct {
key string
label string
value string
}
type panelLine struct {
text string
selected bool
}
type uiModel struct {
app *App
viewport viewport.Model
input textinput.Model
ready bool
width int
height int
statusLine string
suggestions []string
content strings.Builder
followTail bool
showModal bool
modalTitle string
modalBody string
panelKind event.UIPanelKind
panelIndex int
forwardItems []forward.Snapshot
pluginItems []luaplugin.Snapshot
modeItems []modeItem
promptActive bool
promptTitle string
promptHint string
promptInput textinput.Model
promptSubmit func(string)
completionActive bool
completionBase string
completionCandidates []string
completionIndex int
}
func newUIModel(app *App) *uiModel {
in := textinput.New()
// bubbles v0.18.0 computes placeholder width using display cells,
// which can panic on CJK placeholders. Keep this ASCII-only.
in.Placeholder = "Type to send to remote, use .help for commands"
in.Focus()
in.CharLimit = 0
in.Prompt = "> "
in.Width = 80
return &uiModel{app: app, input: in, followTail: true}
}
func (m *uiModel) Init() tea.Cmd {
return tea.Batch(waitUIEvent(m.app.uiEvents), waitDone(m.app.waitDone()), textinput.Blink)
}
func waitUIEvent(ch <-chan event.UIEvent) tea.Cmd {
return func() tea.Msg {
ev, ok := <-ch
if !ok {
return doneMsg{}
}
return ev
}
}
func waitDone(ch <-chan struct{}) tea.Cmd {
return func() tea.Msg {
<-ch
return doneMsg{}
}
}
func (m *uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case doneMsg:
return m, tea.Quit
case event.UIEvent:
switch msg.Kind {
case event.UIEventOutput, event.UIEventStatus:
if msg.Kind == event.UIEventOutput {
m.appendOutput(msg.Text)
} else {
m.statusLine = msg.Text
}
case event.UIEventModal:
m.showModal = true
m.panelKind = event.UIPanelNone
m.modalTitle = msg.Title
m.modalBody = msg.Text
m.promptActive = false
case event.UIEventPanel:
m.openPanel(msg.Panel)
}
return m, waitUIEvent(m.app.uiEvents)
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
inputHeight := 3
statusHeight := 2
viewportHeight := msg.Height - inputHeight - statusHeight
if viewportHeight < 3 {
viewportHeight = 3
}
if !m.ready {
m.viewport = viewport.New(msg.Width, viewportHeight)
m.viewport.YPosition = 0
m.viewport.SetContent(m.content.String())
m.ready = true
} else {
m.viewport.Width = msg.Width
m.viewport.Height = viewportHeight
}
m.input.Width = msg.Width - 4
m.viewport.GotoBottom()
m.followTail = true
return m, nil
case tea.KeyMsg:
keyStr := strings.ToLower(msg.String())
if m.handleViewportKey(msg) {
return m, nil
}
if keyStr != "tab" && keyStr != "shift+tab" {
m.resetCompletion()
}
if m.showModal && m.handleModalKey(msg) {
return m, nil
}
if m.isLocalHotkey(keyStr, "c") {
m.app.Statusf("[local] exiting by %s+C", strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod)))
m.app.Close()
return m, tea.Quit
}
if handleLocalHotkey(m, keyStr) {
return m, nil
}
// Some terminals can't encode Ctrl+Alt/Shift+H distinctly and report Ctrl+H.
if keyStr == "ctrl+h" {
handleLocalHotkey(m, hotkeyWith(m.app.cfg.HotkeyMod, "h"))
return m, nil
}
if letter, ok := parseCtrlKey(keyStr); ok {
if err := m.app.sendCtrl(letter); err != nil {
m.app.Notifyf("[remote] ctrl send failed: %v", err)
}
return m, nil
}
switch keyStr {
case "f1":
handleLocalHotkey(m, hotkeyWith(m.app.cfg.HotkeyMod, "h"))
return m, nil
case "tab", "shift+tab":
direction := 1
if keyStr == "shift+tab" {
direction = -1
}
if m.completionActive && len(m.completionCandidates) > 0 {
m.stepCompletion(direction)
return m, nil
}
line, cands := m.app.dispatcher.Complete(m.input.Value())
m.suggestions = cands
if len(cands) == 0 {
return m, nil
}
if len(cands) == 1 {
m.input.SetValue(line)
return m, nil
}
m.completionActive = true
m.completionBase = completionBase(m.input.Value())
m.completionCandidates = append([]string(nil), cands...)
if direction < 0 {
m.completionIndex = len(cands) - 1
} else {
m.completionIndex = 0
}
m.applyCompletion()
return m, nil
case "enter":
line := m.input.Value()
m.input.SetValue("")
m.suggestions = nil
m.followTail = true
m.app.handleLine(line)
return m, nil
}
}
var cmd tea.Cmd
m.input, cmd = m.input.Update(msg)
return m, cmd
}
func (m *uiModel) View() string {
if !m.ready {
return "Initializing..."
}
suggest := "Tab: no candidates"
if len(m.suggestions) > 1 {
suggest = "Tab candidates: " + strings.Join(m.suggestions, " ")
} else if len(m.suggestions) == 1 {
suggest = "Tab: " + m.suggestions[0]
}
modifier := strings.ToUpper(normalizeHotkeyPrefix(m.app.cfg.HotkeyMod))
hotkeys := "Hotkeys: Ctrl+C remote | " + modifier + "+C local | " + modifier + "+F forward | " + modifier + "+P plugins | " + modifier + "+M mode | F1 help"
hotkeys = lipgloss.NewStyle().Faint(true).Foreground(lipgloss.Color("245")).Render(hotkeys)
status := m.statusLine
if status == "" {
status = "Ready"
}
status = lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Faint(true).Render(status)
base := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", m.viewport.View(), suggest, status, m.input.View(), hotkeys)
if !m.showModal {
return fillScreen(m.width, m.height, base)
}
if m.promptActive {
return renderCenteredModalContent(m.width, m.height, m.renderPrompt())
}
if m.panelKind != event.UIPanelNone {
return renderCenteredModalContent(m.width, m.height, m.renderPanel())
}
return renderCenteredModal(m.width, m.height, m.modalTitle, m.modalBody)
}
+322
View File
@@ -0,0 +1,322 @@
package termapp
import (
"fmt"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/jixishi/SerialTerminalForWindowsTerminal/internal/event"
)
func (m *uiModel) handleModalKey(msg tea.KeyMsg) bool {
keyStr := strings.ToLower(msg.String())
if m.promptActive {
return m.handlePromptKey(msg)
}
if keyStr == "esc" {
m.closeModal()
return true
}
if m.panelKind == event.UIPanelNone {
if keyStr == "enter" {
m.closeModal()
}
return true
}
switch m.panelKind {
case event.UIPanelForward:
return m.handleForwardPanelKey(keyStr)
case event.UIPanelPlugin:
return m.handlePluginPanelKey(keyStr)
case event.UIPanelMode:
return m.handleModePanelKey(keyStr)
default:
return true
}
}
func (m *uiModel) closeModal() {
m.showModal = false
m.panelKind = event.UIPanelNone
m.modalTitle = ""
m.modalBody = ""
m.promptActive = false
m.promptSubmit = nil
}
func (m *uiModel) openPanel(kind event.UIPanelKind) {
m.showModal = true
m.panelKind = kind
m.panelIndex = 0
m.promptActive = false
m.promptSubmit = nil
m.refreshPanel()
}
func (m *uiModel) refreshPanel() {
switch m.panelKind {
case event.UIPanelForward:
m.forwardItems = m.app.forward.List()
m.panelIndex = clampIndex(m.panelIndex, len(m.forwardItems))
case event.UIPanelPlugin:
m.pluginItems = m.app.plugins.List()
m.panelIndex = clampIndex(m.panelIndex, len(m.pluginItems))
case event.UIPanelMode:
m.modeItems = m.buildModeItems()
m.panelIndex = clampIndex(m.panelIndex, len(m.modeItems))
}
}
func (m *uiModel) buildModeItems() []modeItem {
return []modeItem{{"in", "Input Charset", m.app.cfg.InputCode}, {"out", "Output Charset", m.app.cfg.OutputCode}, {"end", "Line End", fmt.Sprintf("%q", m.app.cfg.EndStr)}, {"frame", "Hex Frame Size", fmt.Sprintf("%d", m.app.cfg.FrameSize)}, {"timestamp", "Timestamp", fmt.Sprintf("%v", m.app.cfg.TimesTamp)}, {"timefmt", "Timestamp Format", m.app.cfg.TimesFmt}}
}
func (m *uiModel) handleForwardPanelKey(key string) bool {
switch key {
case "up", "k":
if m.panelIndex > 0 {
m.panelIndex--
}
return true
case "down", "j":
if m.panelIndex < len(m.forwardItems)-1 {
m.panelIndex++
}
return true
case "r":
m.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: <tcp|udp> <address>")
return
}
m.app.handleLine(fmt.Sprintf(".forward add %s %s", parts[0], parts[1]))
m.refreshPanel()
})
return true
}
if len(m.forwardItems) == 0 {
return true
}
sel := m.forwardItems[m.panelIndex]
switch key {
case "enter":
if sel.Enabled {
m.app.handleLine(fmt.Sprintf(".forward disable %d", sel.ID))
} else {
m.app.handleLine(fmt.Sprintf(".forward enable %d", sel.ID))
}
m.refreshPanel()
return true
case "d", "delete", "backspace":
m.app.handleLine(fmt.Sprintf(".forward remove %d", sel.ID))
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: <tcp|udp> <address>")
return
}
m.app.handleLine(fmt.Sprintf(".forward update %d %s %s", sel.ID, parts[0], parts[1]))
m.refreshPanel()
})
return true
default:
return true
}
}
func (m *uiModel) handlePluginPanelKey(key string) bool {
switch key {
case "up", "k":
if m.panelIndex > 0 {
m.panelIndex--
}
return true
case "down", "j":
if m.panelIndex < len(m.pluginItems)-1 {
m.panelIndex++
}
return true
case "r":
m.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")
return
}
m.app.handleLine(fmt.Sprintf(".plugin load %s", path))
m.refreshPanel()
})
return true
}
if len(m.pluginItems) == 0 {
return true
}
sel := m.pluginItems[m.panelIndex]
switch key {
case "enter":
if sel.Enabled {
m.app.handleLine(fmt.Sprintf(".plugin disable %s", sel.Name))
} else {
m.app.handleLine(fmt.Sprintf(".plugin enable %s", sel.Name))
}
m.refreshPanel()
return true
case "u":
m.app.handleLine(fmt.Sprintf(".plugin reload %s", sel.Name))
m.refreshPanel()
return true
case "d", "delete", "backspace":
m.app.handleLine(fmt.Sprintf(".plugin unload %s", sel.Name))
m.refreshPanel()
return true
default:
return true
}
}
func (m *uiModel) handleModePanelKey(key string) bool {
switch key {
case "up", "k":
if m.panelIndex > 0 {
m.panelIndex--
}
return true
case "down", "j":
if m.panelIndex < len(m.modeItems)-1 {
m.panelIndex++
}
return true
case "r":
m.refreshPanel()
return true
}
if len(m.modeItems) == 0 {
return true
}
sel := m.modeItems[m.panelIndex]
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")
}
m.refreshPanel()
}
return true
case "enter", "e":
initial := strings.Trim(sel.value, "\"")
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.refreshPanel()
})
return true
default:
return true
}
}
func (m *uiModel) startPrompt(title, hint, initial string, submit func(string)) {
in := textinput.New()
in.Prompt = "> "
in.Placeholder = hint
in.SetValue(initial)
in.Focus()
in.CharLimit = 0
in.Width = 64
m.promptActive = true
m.promptTitle = title
m.promptHint = hint
m.promptInput = in
m.promptSubmit = submit
}
func (m *uiModel) handlePromptKey(msg tea.KeyMsg) bool {
key := strings.ToLower(msg.String())
switch key {
case "esc":
m.promptActive = false
m.promptSubmit = nil
return true
case "enter":
value := strings.TrimSpace(m.promptInput.Value())
submit := m.promptSubmit
m.promptActive = false
m.promptSubmit = nil
if submit != nil {
submit(value)
}
return true
default:
var cmd tea.Cmd
m.promptInput, cmd = m.promptInput.Update(msg)
_ = cmd
return true
}
}
func (m *uiModel) renderPanel() string {
switch m.panelKind {
case event.UIPanelForward:
return m.renderForwardPanel()
case event.UIPanelPlugin:
return m.renderPluginPanel()
case event.UIPanelMode:
return m.renderModePanel()
default:
return renderModal("Info", "No panel", m.availableModalWidth())
}
}
func (m *uiModel) renderForwardPanel() string {
lines := make([]panelLine, 0, len(m.forwardItems)+2)
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"})
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})
}
}
return renderPanelModal("Forward Panel", lines, "Up/Down select | Enter toggle enable | 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)
if len(m.pluginItems) == 0 {
lines = append(lines, panelLine{text: "No plugins loaded. Press 'l' to load one."})
} else {
lines = append(lines, panelLine{text: "Name Enabled Path"})
for i, it := range m.pluginItems {
lines = append(lines, panelLine{text: fmt.Sprintf("%-20s %-7v %s", it.Name, it.Enabled, it.Path), selected: i == m.panelIndex})
}
}
return renderPanelModal("Plugin Panel", lines, "Up/Down select | Enter toggle enable | 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)
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())
}
+214
View File
@@ -0,0 +1,214 @@
package termapp
import (
"strings"
"github.com/charmbracelet/lipgloss"
)
func (m *uiModel) appendOutput(text string) {
if text == "" {
return
}
m.content.WriteString(text)
if m.ready {
m.viewport.SetContent(m.content.String())
if m.followTail {
m.viewport.GotoBottom()
}
}
}
func (m *uiModel) renderPrompt() string {
lines := []boxLine{
{text: m.promptHint, style: modalBodyLineStyle()},
{text: m.promptInput.View(), style: modalBodyLineStyle()},
{text: "Enter submit | Esc cancel", style: modalFooterLineStyle()},
}
return renderBox(m.promptTitle, lines, 48, m.availableModalWidth())
}
func renderModal(title, body string, maxWidth int) string {
if title == "" {
title = "Info"
}
parts := strings.Split(strings.ReplaceAll(body, "\r\n", "\n"), "\n")
if len(parts) > 12 {
parts = append(parts[:12], "... (press Esc/Enter to close)")
}
lines := make([]boxLine, 0, len(parts))
for _, part := range parts {
lines = append(lines, boxLine{text: part, style: modalBodyLineStyle()})
}
return renderBox(title, lines, 20, maxWidth)
}
func renderPanelModal(title string, lines []panelLine, footer string, maxWidth int) string {
boxLines := make([]boxLine, 0, len(lines)+1)
for _, line := range lines {
style := modalBodyLineStyle()
prefix := " "
if line.selected {
style = selectedPanelLineStyle()
prefix = "▸ "
}
boxLines = append(boxLines, boxLine{text: prefix + line.text, style: style})
}
boxLines = append(boxLines, boxLine{text: footer, style: modalFooterLineStyle()})
return renderBox(title, boxLines, 40, maxWidth)
}
func fillScreen(width, height int, content string) string {
if width <= 0 || height <= 0 {
return content
}
return lipgloss.Place(width, height, lipgloss.Left, lipgloss.Top, content,
lipgloss.WithWhitespaceChars(" "),
lipgloss.WithWhitespaceForeground(lipgloss.Color("0")),
)
}
func renderCenteredModal(width, height int, title, body string) string {
maxWidth := width - 8
if maxWidth < 20 {
maxWidth = 20
}
return renderCenteredModalContent(width, height, renderModal(title, body, maxWidth))
}
func renderCenteredModalContent(width, height int, content string) string {
if width <= 0 || height <= 0 {
return content
}
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()
}
func (m *uiModel) availableModalWidth() int {
if m.width <= 0 {
return 100
}
maxWidth := m.width - 8
if maxWidth < 20 {
maxWidth = 20
}
return maxWidth
}
type boxLine struct {
text string
style lipgloss.Style
}
func renderBox(title string, lines []boxLine, minWidth, maxWidth int) string {
contentWidth := lipgloss.Width(title)
for _, line := range lines {
contentWidth = maxInt(contentWidth, lipgloss.Width(line.text))
}
contentWidth = maxInt(minWidth, contentWidth)
contentWidth = minInt(contentWidth, maxWidth)
top := "╭" + strings.Repeat("─", contentWidth+2) + "╮"
bottom := "╰" + strings.Repeat("─", contentWidth+2) + "╯"
rows := make([]string, 0, len(lines)+3)
rows = append(rows, top)
rows = append(rows, renderBoxRow(modalHeaderLineStyle(), title, contentWidth))
for _, line := range lines {
rows = append(rows, renderBoxRow(line.style, truncateToWidth(line.text, contentWidth), contentWidth))
}
rows = append(rows, bottom)
return strings.Join(rows, "\n")
}
func renderBoxRow(contentStyle lipgloss.Style, text string, width int) string {
visible := truncateToWidth(text, width)
pad := strings.Repeat(" ", maxInt(0, width-lipgloss.Width(visible)))
inner := contentStyle.Render(visible) + pad
return "│ " + inner + " │"
}
func modalHeaderLineStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("25"))
}
func modalBodyLineStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("252")).Background(lipgloss.Color("236"))
}
func modalFooterLineStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("250")).Background(lipgloss.Color("236"))
}
func selectedPanelLineStyle() lipgloss.Style {
return lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("230")).Background(lipgloss.Color("31"))
}
func truncateToWidth(s string, width int) string {
if width <= 0 || lipgloss.Width(s) <= width {
return s
}
var b strings.Builder
for _, r := range s {
next := b.String() + string(r)
if lipgloss.Width(next) > width {
break
}
b.WriteRune(r)
}
return b.String()
}
func clampIndex(idx, n int) int {
if n <= 0 || idx < 0 {
return 0
}
if idx >= n {
return n - 1
}
return idx
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func maxInt(a int, rest ...int) int {
max := a
for _, v := range rest {
if v > max {
max = v
}
}
return max
}
+309
View File
@@ -0,0 +1,309 @@
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")
}
}