diff --git a/cmd/cloudstic/cmd_tui_input.go b/cmd/cloudstic/cmd_tui_input.go index 4c17380..cc215ca 100644 --- a/cmd/cloudstic/cmd_tui_input.go +++ b/cmd/cloudstic/cmd_tui_input.go @@ -5,14 +5,16 @@ import ( "fmt" "io" "os" + "strconv" + "strings" "github.com/cloudstic/cli/internal/tui" ) -type tuiAction int +type tuiActionKind int const ( - tuiActionNone tuiAction = iota + tuiActionNone tuiActionKind = iota tuiActionUp tuiActionDown tuiActionRun @@ -20,9 +22,15 @@ const ( tuiActionCreate tuiActionEdit tuiActionDelete + tuiActionSelectProfile tuiActionQuit ) +type tuiAction struct { + Kind tuiActionKind + Profile string +} + func ensureSelectedProfile(d tui.Dashboard) tui.Dashboard { if d.SelectedProfile != "" || len(d.Profiles) == 0 { return d @@ -68,64 +76,67 @@ func moveTUISelection(d tui.Dashboard, delta int) tui.Dashboard { return d } -func readTUIAction(r io.ByteReader) (tuiAction, error) { +func readTUIAction(r io.ByteReader, layout tui.DashboardLayout) (tuiAction, error) { b, err := r.ReadByte() if err != nil { - return tuiActionNone, err + return tuiAction{}, err } switch b { case 'q', 'Q': - return tuiActionQuit, nil + return tuiAction{Kind: tuiActionQuit}, nil case 'j', 'J': - return tuiActionDown, nil + return tuiAction{Kind: tuiActionDown}, nil case 'k', 'K': - return tuiActionUp, nil + return tuiAction{Kind: tuiActionUp}, nil case 'b', 'B': - return tuiActionRun, nil + return tuiAction{Kind: tuiActionRun}, nil case 'c', 'C': - return tuiActionCheck, nil + return tuiAction{Kind: tuiActionCheck}, nil case 'n', 'N': - return tuiActionCreate, nil + return tuiAction{Kind: tuiActionCreate}, nil case 'e', 'E': - return tuiActionEdit, nil + return tuiAction{Kind: tuiActionEdit}, nil case 'd', 'D': - return tuiActionDelete, nil + return tuiAction{Kind: tuiActionDelete}, nil case 0x1b: next, err := r.ReadByte() if err != nil { - return tuiActionNone, nil + return tuiAction{}, nil } if next == 'O' { dir, err := r.ReadByte() if err != nil { - return tuiActionNone, nil + return tuiAction{}, nil } switch dir { case 'A': - return tuiActionUp, nil + return tuiAction{Kind: tuiActionUp}, nil case 'B': - return tuiActionDown, nil + return tuiAction{Kind: tuiActionDown}, nil default: - return tuiActionNone, nil + return tuiAction{}, nil } } if next != '[' { - return tuiActionNone, nil + return tuiAction{}, nil } csi, err := readTUICSISequence(r) if err != nil || len(csi) == 0 { - return tuiActionNone, nil + return tuiAction{}, nil + } + if csi[0] == '<' { + return parseTUIMouseAction(csi, layout) } switch csi[len(csi)-1] { case 'A': - return tuiActionUp, nil + return tuiAction{Kind: tuiActionUp}, nil case 'B': - return tuiActionDown, nil + return tuiAction{Kind: tuiActionDown}, nil default: - return tuiActionNone, nil + return tuiAction{}, nil } default: - return tuiActionNone, nil + return tuiAction{}, nil } } @@ -146,6 +157,47 @@ func readTUICSISequence(r io.ByteReader) ([]byte, error) { } } +func parseTUIMouseAction(csi []byte, layout tui.DashboardLayout) (tuiAction, error) { + if len(csi) < 2 { + return tuiAction{}, nil + } + final := csi[len(csi)-1] + if final != 'M' { + return tuiAction{}, nil + } + parts := strings.Split(string(csi[1:len(csi)-1]), ";") + if len(parts) != 3 { + return tuiAction{}, nil + } + button, err := strconv.Atoi(parts[0]) + if err != nil || button != 0 { + return tuiAction{}, nil + } + x, err := strconv.Atoi(parts[1]) + if err != nil { + return tuiAction{}, nil + } + y, err := strconv.Atoi(parts[2]) + if err != nil { + return tuiAction{}, nil + } + if !pointInRect(x, y, layout.ProfileRect) { + return tuiAction{}, nil + } + profile := layout.ProfileRows[y] + if profile == "" { + return tuiAction{}, nil + } + return tuiAction{Kind: tuiActionSelectProfile, Profile: profile}, nil +} + +func pointInRect(x, y int, rect tui.Rect) bool { + if rect.W <= 0 || rect.H <= 0 { + return false + } + return x >= rect.X && x < rect.X+rect.W && y >= rect.Y && y < rect.Y+rect.H +} + func runSelectedTUIAction(ctx context.Context, r *runner, profilesFile string, dashboard tui.Dashboard, log *tuiActionState) error { profile, ok := selectedTUIProfile(dashboard) if !ok { diff --git a/cmd/cloudstic/cmd_tui_test.go b/cmd/cloudstic/cmd_tui_test.go index 1b6ed75..339d5c0 100644 --- a/cmd/cloudstic/cmd_tui_test.go +++ b/cmd/cloudstic/cmd_tui_test.go @@ -500,92 +500,109 @@ func TestRunTUI_CreateActionUsesModalAndSavesProfile(t *testing.T) { } func TestReadTUIAction_ParsesCSIArrowKeys(t *testing.T) { - ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A"))) + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[A")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction up: %v", err) } - if ev != tuiActionUp { - t.Fatalf("up action=%v want %v", ev, tuiActionUp) + if ev.Kind != tuiActionUp { + t.Fatalf("up action=%v want %v", ev.Kind, tuiActionUp) } - ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[B"))) + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[B")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction down: %v", err) } - if ev != tuiActionDown { - t.Fatalf("down action=%v want %v", ev, tuiActionDown) + if ev.Kind != tuiActionDown { + t.Fatalf("down action=%v want %v", ev.Kind, tuiActionDown) } } func TestReadTUIAction_ParsesParameterizedCSIArrowKeys(t *testing.T) { - ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2A"))) + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2A")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction param up: %v", err) } - if ev != tuiActionUp { - t.Fatalf("param up action=%v want %v", ev, tuiActionUp) + if ev.Kind != tuiActionUp { + t.Fatalf("param up action=%v want %v", ev.Kind, tuiActionUp) } - ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2B"))) + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[1;2B")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction param down: %v", err) } - if ev != tuiActionDown { - t.Fatalf("param down action=%v want %v", ev, tuiActionDown) + if ev.Kind != tuiActionDown { + t.Fatalf("param down action=%v want %v", ev.Kind, tuiActionDown) } } func TestReadTUIAction_ParsesSS3ArrowKeys(t *testing.T) { - ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOA"))) + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOA")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction ss3 up: %v", err) } - if ev != tuiActionUp { - t.Fatalf("ss3 up action=%v want %v", ev, tuiActionUp) + if ev.Kind != tuiActionUp { + t.Fatalf("ss3 up action=%v want %v", ev.Kind, tuiActionUp) } - ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOB"))) + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1bOB")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction ss3 down: %v", err) } - if ev != tuiActionDown { - t.Fatalf("ss3 down action=%v want %v", ev, tuiActionDown) + if ev.Kind != tuiActionDown { + t.Fatalf("ss3 down action=%v want %v", ev.Kind, tuiActionDown) } } func TestReadTUIAction_ParsesCheckShortcut(t *testing.T) { - ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("c"))) + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("c")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction check: %v", err) } - if ev != tuiActionCheck { - t.Fatalf("check action=%v want %v", ev, tuiActionCheck) + if ev.Kind != tuiActionCheck { + t.Fatalf("check action=%v want %v", ev.Kind, tuiActionCheck) } } func TestReadTUIAction_ParsesManagementShortcuts(t *testing.T) { - ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n"))) + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("n")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction create: %v", err) } - if ev != tuiActionCreate { - t.Fatalf("create action=%v want %v", ev, tuiActionCreate) + if ev.Kind != tuiActionCreate { + t.Fatalf("create action=%v want %v", ev.Kind, tuiActionCreate) } - ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("e"))) + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("e")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction edit: %v", err) } - if ev != tuiActionEdit { - t.Fatalf("edit action=%v want %v", ev, tuiActionEdit) + if ev.Kind != tuiActionEdit { + t.Fatalf("edit action=%v want %v", ev.Kind, tuiActionEdit) } - ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("d"))) + ev, err = readTUIAction(bufio.NewReader(bytes.NewBufferString("d")), tui.DashboardLayout{}) if err != nil { t.Fatalf("readTUIAction delete: %v", err) } - if ev != tuiActionDelete { - t.Fatalf("delete action=%v want %v", ev, tuiActionDelete) + if ev.Kind != tuiActionDelete { + t.Fatalf("delete action=%v want %v", ev.Kind, tuiActionDelete) + } +} + +func TestReadTUIAction_ParsesProfileClick(t *testing.T) { + layout := tui.DashboardLayout{ + ProfileRows: map[int]string{8: "photos"}, + ProfileRect: tui.Rect{X: 1, Y: 5, W: 20, H: 6}, + } + ev, err := readTUIAction(bufio.NewReader(bytes.NewBufferString("\x1b[<0;5;8M")), layout) + if err != nil { + t.Fatalf("readTUIAction click: %v", err) + } + if ev.Kind != tuiActionSelectProfile { + t.Fatalf("click action=%v want %v", ev.Kind, tuiActionSelectProfile) + } + if ev.Profile != "photos" { + t.Fatalf("click profile=%q want photos", ev.Profile) } } @@ -697,7 +714,7 @@ func TestTUISession_HandleActionRunRefreshesDashboard(t *testing.T) { }, }) - if _, err := s.handleAction(context.Background(), tuiActionRun); err != nil { + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionRun}); err != nil { t.Fatalf("handleAction(run): %v", err) } if s.dashboard.SelectedProfile != "docs" { @@ -771,7 +788,7 @@ func TestTUISession_HandleActionRunRefreshFailureRestoresRawMode(t *testing.T) { }) s.rawState = state - if _, err := s.handleAction(context.Background(), tuiActionRun); err == nil { + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionRun}); err == nil { t.Fatalf("expected refresh failure") } if madeRaw != 1 || restored != 1 { @@ -817,7 +834,7 @@ func TestTUISession_HandleActionCreateRefreshesDashboard(t *testing.T) { var out strings.Builder s := newTUISession(&runner{out: &out, stdoutFile: os.Stdout, stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, profilesPath, tui.Dashboard{}) - if _, err := s.handleAction(context.Background(), tuiActionCreate); err != nil { + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionCreate}); err != nil { t.Fatalf("handleAction(create): %v", err) } if s.dashboard.SelectedProfile != "photos" { @@ -869,7 +886,7 @@ func TestTUISession_HandleActionDeleteRefreshesDashboard(t *testing.T) { s.r.stdin = readEnd s.r.lineIn = bufio.NewReader(readEnd) s.profilesFile = profilesPath - if _, err := s.handleAction(context.Background(), tuiActionDelete); err != nil { + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionDelete}); err != nil { t.Fatalf("handleAction(delete): %v", err) } if s.dashboard.SelectedProfile != "photos" { @@ -880,6 +897,24 @@ func TestTUISession_HandleActionDeleteRefreshesDashboard(t *testing.T) { } } +func TestTUISession_HandleActionSelectProfileRefreshesSelection(t *testing.T) { + stubTUITestHooks(t) + + s := newTUISession(&runner{out: io.Discard, stdoutFile: os.Stdout, stdin: os.Stdin}, "profiles.yaml", tui.Dashboard{ + SelectedProfile: "docs", + Profiles: []tui.ProfileCard{ + {Name: "docs", Source: "local:/docs", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, + {Name: "photos", Source: "local:/photos", StoreRef: "remote", Enabled: true, Status: tui.ProfileStatusReady}, + }, + }) + if _, err := s.handleAction(context.Background(), tuiAction{Kind: tuiActionSelectProfile, Profile: "photos"}); err != nil { + t.Fatalf("handleAction(select): %v", err) + } + if s.dashboard.SelectedProfile != "photos" { + t.Fatalf("selected profile=%q want photos", s.dashboard.SelectedProfile) + } +} + func TestTUISession_RefreshPreservesSelectionAndActivity(t *testing.T) { oldBuild := tuiBuildDashboard t.Cleanup(func() { tuiBuildDashboard = oldBuild }) @@ -992,7 +1027,7 @@ func TestReadInput_ClosesChannelOnEOF(t *testing.T) { _ = writeEnd.Close() s := newTUISession(&runner{stdin: readEnd, lineIn: bufio.NewReader(readEnd)}, "", tui.Dashboard{}) - readPermitCh := make(chan struct{}, 1) + readPermitCh := make(chan tui.DashboardLayout, 1) eventCh := make(chan tuiAction, 2) errCh := make(chan error, 1) close(readPermitCh) diff --git a/cmd/cloudstic/tui_runtime.go b/cmd/cloudstic/tui_runtime.go index 3a0f38c..9b1502e 100644 --- a/cmd/cloudstic/tui_runtime.go +++ b/cmd/cloudstic/tui_runtime.go @@ -101,12 +101,12 @@ func (b tuiCLIBackend) CheckProfile(ctx context.Context, profilesFile, profileNa } func defaultEnterAltScreen(w io.Writer) error { - _, err := fmt.Fprint(w, "\x1b[?1049h\x1b[?1007h\x1b[2J\x1b[H\x1b[?25l") + _, err := fmt.Fprint(w, "\x1b[?1049h\x1b[?1000h\x1b[?1006h\x1b[2J\x1b[H\x1b[?25l") return err } func defaultLeaveAltScreen(w io.Writer) error { - _, err := fmt.Fprint(w, "\x1b[?25h\x1b[?1007l\x1b[?1049l") + _, err := fmt.Fprint(w, "\x1b[?25h\x1b[?1006l\x1b[?1000l\x1b[?1049l") return err } @@ -166,10 +166,10 @@ func (s *tuiSession) run(ctx context.Context) int { } eventCh := make(chan tuiAction, 32) - readPermitCh := make(chan struct{}, 1) + readPermitCh := make(chan tui.DashboardLayout, 1) readErrCh := make(chan error, 1) go s.readInput(readPermitCh, eventCh, readErrCh) - readPermitCh <- struct{}{} + readPermitCh <- tui.LayoutDashboardWidth(s.dashboard, tuiWidth(s.r)) resizeCh := make(chan os.Signal, 1) tuiNotifyResize(resizeCh) @@ -198,7 +198,7 @@ func (s *tuiSession) run(ctx context.Context) int { if code >= 0 { return code } - readPermitCh <- struct{}{} + readPermitCh <- tui.LayoutDashboardWidth(s.dashboard, tuiWidth(s.r)) } } } @@ -256,10 +256,10 @@ func (s *tuiSession) render() error { return renderTUIScreenWidth(s.r.out, s.dashboard, tuiWidth(s.r)) } -func (s *tuiSession) readInput(readPermitCh <-chan struct{}, eventCh chan<- tuiAction, readErrCh chan<- error) { +func (s *tuiSession) readInput(readPermitCh <-chan tui.DashboardLayout, eventCh chan<- tuiAction, readErrCh chan<- error) { defer close(eventCh) - for range readPermitCh { - event, err := readTUIAction(s.r.lineReader()) + for layout := range readPermitCh { + event, err := readTUIAction(s.r.lineReader(), layout) if err != nil { if err != io.EOF { readErrCh <- err @@ -271,13 +271,17 @@ func (s *tuiSession) readInput(readPermitCh <-chan struct{}, eventCh chan<- tuiA } func (s *tuiSession) handleAction(ctx context.Context, action tuiAction) (int, error) { - switch action { + switch action.Kind { case tuiActionQuit: return 0, nil case tuiActionUp: s.dashboard = moveTUISelection(s.dashboard, -1) case tuiActionDown: s.dashboard = moveTUISelection(s.dashboard, 1) + case tuiActionSelectProfile: + if action.Profile != "" { + s.dashboard.SelectedProfile = action.Profile + } case tuiActionRun: if err := s.runSuspended(ctx, func(ctx context.Context) error { s.dashboard = runTUIActionIntoDashboard(ctx, s.r, s.profilesFile, s.dashboard) diff --git a/internal/tui/shell.go b/internal/tui/shell.go index 5a055c6..654ff6b 100644 --- a/internal/tui/shell.go +++ b/internal/tui/shell.go @@ -18,6 +18,7 @@ type Rect struct { type DashboardLayout struct { ProfileRows map[int]string + ProfileRect Rect ActionRect Rect } @@ -61,8 +62,15 @@ func LayoutDashboardWidth(d Dashboard, width int) DashboardLayout { rightLines := renderSelectedProfile(d) leftLines, rightLines = equalizePaneHeights(leftLines, rightLines) leftBox := boxLinesExact("Profiles", leftLines, profilesWidth) + leftWidth := longestVisible(leftBox) + layout.ProfileRect = Rect{ + X: 1, + Y: y, + W: leftWidth, + H: len(leftBox), + } - rightStartX := longestVisible(leftBox) + 3 + rightStartX := leftWidth + 3 contentStartY := y + 3 for i, profile := range d.Profiles { layout.ProfileRows[contentStartY+i] = profile.Name diff --git a/internal/tui/shell_test.go b/internal/tui/shell_test.go index 70c6c89..d098e4f 100644 --- a/internal/tui/shell_test.go +++ b/internal/tui/shell_test.go @@ -211,6 +211,12 @@ func TestLayoutDashboardWidth_TracksProfileRowsAndActionRect(t *testing.T) { if !foundDocs || !foundPhotos { t.Fatalf("unexpected profile row mapping: %+v", layout.ProfileRows) } + if layout.ProfileRect.W <= 0 || layout.ProfileRect.H <= 0 { + t.Fatalf("unexpected profile rect: %+v", layout.ProfileRect) + } + if layout.ProfileRect.X != 1 || layout.ProfileRect.Y <= 0 { + t.Fatalf("unexpected profile rect origin: %+v", layout.ProfileRect) + } if layout.ActionRect.W <= 0 || layout.ActionRect.H != 1 { t.Fatalf("unexpected action rect: %+v", layout.ActionRect) }