From 94833d6f4392c951ac621913137ca7dffd75014f Mon Sep 17 00:00:00 2001 From: Glauber Ferreira Date: Tue, 8 Sep 2026 11:16:15 -0300 Subject: [PATCH] =?UTF-8?q?remove=20opera=C3=A7=C3=A3o=20remove,=20corrige?= =?UTF-8?q?=20bug=20de=20focus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ops/ops.go | 24 ++++++---------- internal/ops/ops_test.go | 27 +++++++++++++++--- internal/ui/app.go | 6 +++- internal/ui/app_test.go | 48 +++++++++++++++++++++++++++++++ internal/ui/dialogs.go | 61 ++++++++++++++++++++++++++++------------ 5 files changed, 128 insertions(+), 38 deletions(-) diff --git a/internal/ops/ops.go b/internal/ops/ops.go index 1a319fb..3d4d92c 100644 --- a/internal/ops/ops.go +++ b/internal/ops/ops.go @@ -18,7 +18,6 @@ type Kind string // The supported operation kinds. const ( KindReplace Kind = "Replace" - KindRemove Kind = "Remove" KindInsert Kind = "Insert" KindIncrement Kind = "Increment" KindLower Kind = "To lowercase" @@ -29,7 +28,6 @@ const ( // Kinds lists every supported operation kind, in menu order. var Kinds = []Kind{ KindReplace, - KindRemove, KindInsert, KindIncrement, KindLower, @@ -48,9 +46,9 @@ const ToLast = "to-last" type Config struct { Kind Kind - Target string // Replace, Remove: the text or pattern to look for. + Target string // Replace: the text or pattern to look for. Replacement string // Replace: the text to put in its place. - Limit string // Replace, Remove: max number of matches, empty or 0 for all. + Limit string // Replace: max number of matches, empty or 0 for all. Text string // Insert: the text to insert. Index string // Insert: character position, or ToLast for the end. Prefix string // Increment: text placed before the counter. @@ -59,9 +57,9 @@ type Config struct { First string // Truncate: first character index, or ToLast. Last string // Truncate: last character index, or ToLast. - FromStart bool // Replace, Remove: apply Limit to the first matches instead of the last. - CaseSensitive bool // Replace, Remove: match letter case exactly. - Regex bool // Replace, Remove: treat Target as a regular expression. + FromStart bool // Replace: apply Limit to the first matches instead of the last. + CaseSensitive bool // Replace: match letter case exactly. + Regex bool // Replace: treat Target as a regular expression. KeepBetween bool // Truncate: keep the selected range instead of removing it. PreserveExt bool // All kinds: leave the file extension untouched. } @@ -98,7 +96,7 @@ type Operation interface { // cannot be parsed or a regular expression does not compile. func New(cfg Config) (Operation, error) { switch cfg.Kind { - case KindReplace, KindRemove: + case KindReplace: return newSubstitute(cfg) case KindInsert: return newInsert(cfg) @@ -185,8 +183,8 @@ func compileTarget(cfg Config) (*regexp.Regexp, error) { return re, nil } -// substituteOp implements both [KindReplace] and [KindRemove]; a removal is a -// replacement with empty text. +// substituteOp implements [KindReplace]. Setting the replacement to an empty +// string removes matching text. type substituteOp struct { cfg Config re *regexp.Regexp @@ -253,11 +251,7 @@ func (o *substituteOp) substitute(s string) string { func (o *substituteOp) Summary() string { var b strings.Builder - if o.cfg.Kind == KindRemove { - fmt.Fprintf(&b, "Remove %q", o.cfg.Target) - } else { - fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement) - } + fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement) flags := []string{} if o.limit > 0 { where := "last" diff --git a/internal/ops/ops_test.go b/internal/ops/ops_test.go index 9061265..ac6af30 100644 --- a/internal/ops/ops_test.go +++ b/internal/ops/ops_test.go @@ -103,7 +103,7 @@ func TestReplace(t *testing.T) { } } -func TestRemove(t *testing.T) { +func TestReplaceWithEmptyReplacementRemovesMatches(t *testing.T) { tests := []struct { name string cfg func(c *ops.Config) @@ -134,7 +134,7 @@ func TestRemove(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - cfg := ops.DefaultConfig(ops.KindRemove) + cfg := ops.DefaultConfig(ops.KindReplace) tt.cfg(&cfg) if got := apply(t, cfg, tt.in, 0); got != tt.want { t.Errorf("Apply(%q) = %q, want %q", tt.in, got, tt.want) @@ -282,7 +282,7 @@ func TestNewRejectsBadInput(t *testing.T) { }, { name: "negative limit", - cfg: ops.Config{Kind: ops.KindRemove, Target: "a", Limit: "-2"}, + cfg: ops.Config{Kind: ops.KindReplace, Target: "a", Limit: "-2"}, want: "must not be negative", }, { @@ -321,7 +321,7 @@ func TestNewRejectsBadInput(t *testing.T) { } func TestPreviewAppliesOperationsInOrder(t *testing.T) { - remove := ops.DefaultConfig(ops.KindRemove) + remove := ops.DefaultConfig(ops.KindReplace) remove.Target = "IMG_" lower := ops.DefaultConfig(ops.KindUpper) increment := ops.DefaultConfig(ops.KindIncrement) @@ -399,3 +399,22 @@ func TestDefaultConfig(t *testing.T) { }) } } + +func TestKindsContainsOnlySupportedMenuOperations(t *testing.T) { + want := []ops.Kind{ + ops.KindReplace, + ops.KindInsert, + ops.KindIncrement, + ops.KindLower, + ops.KindUpper, + ops.KindTruncate, + } + if len(ops.Kinds) != len(want) { + t.Fatalf("Kinds = %v, want %v", ops.Kinds, want) + } + for i := range want { + if ops.Kinds[i] != want[i] { + t.Errorf("Kinds[%d] = %q, want %q", i, ops.Kinds[i], want[i]) + } + } +} diff --git a/internal/ui/app.go b/internal/ui/app.go index ab4314d..2805503 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -272,7 +272,11 @@ func (a *App) buildControls(ctx context.Context) { func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey { if name, _ := a.pages.GetFrontPage(); name != pageMain { - // A dialog is open; it handles its own keys. + // A dialog is open. Restore focus to its modal layer before + // dispatch so keys can never reach a page underneath it. + if page := a.pages.GetPage(name); page != nil && !page.HasFocus() { + a.app.SetFocus(page) + } return event } switch event.Key() { diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index dea39c9..7b7cea5 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -240,6 +240,54 @@ func TestAppCancelledDialogLeavesNoOperation(t *testing.T) { }) } +func TestOperationDialogTrapsKeyboardAndMouseFocus(t *testing.T) { + dir := makeDir(t, "one.txt", "two.txt") + app, screen, onLoop := startApp(t, dir) + ctx := context.Background() + + onLoop(func() { + app.openOperationDialog(ctx, ops.KindReplace, ops.DefaultConfig(ops.KindReplace), -1) + // Simulate focus being stolen by an underlying widget. The global key + // guard must restore it before dispatching this key. + app.app.SetFocus(app.filePane) + }) + screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone) + waitForState(t, onLoop, "keyboard focus to return to the operation dialog", func() bool { + page := app.pages.GetPage(pageOperation) + return page != nil && page.HasFocus() && len(app.names) == 2 + }) + + // The folder field lies outside the centered form. Clicking it must be + // consumed by the modal layer rather than focusing the underlying input. + screen.InjectMouse(5, 1, tcell.Button1, tcell.ModNone) + screen.InjectMouse(5, 1, tcell.ButtonNone, tcell.ModNone) + waitForState(t, onLoop, "mouse focus to remain in the operation dialog", func() bool { + page := app.pages.GetPage(pageOperation) + return page != nil && page.HasFocus() && !app.dirInput.HasFocus() + }) +} + +func TestMessageDialogTrapsFocusUntilDismissed(t *testing.T) { + dir := makeDir(t, "one.txt") + app, screen, onLoop := startApp(t, dir) + + onLoop(func() { + app.showMessage("Notice", "Stay modal") + app.app.SetFocus(app.filePane) + }) + screen.InjectKey(tcell.KeyRune, 'x', tcell.ModNone) + waitForState(t, onLoop, "focus to return to the message", func() bool { + page := app.pages.GetPage(pageMessage) + return page != nil && page.HasFocus() && len(app.names) == 1 + }) + + screen.InjectKey(tcell.KeyEscape, 0, tcell.ModNone) + waitForState(t, onLoop, "the message to be dismissed", func() bool { + name, _ := app.pages.GetFrontPage() + return name == pageMain + }) +} + func TestAppRemovesFilesAndOperations(t *testing.T) { dir := makeDir(t, "one.txt", "two.txt") app, screen, onLoop := startApp(t, dir) diff --git a/internal/ui/dialogs.go b/internal/ui/dialogs.go index 24d2acb..087661a 100644 --- a/internal/ui/dialogs.go +++ b/internal/ui/dialogs.go @@ -67,19 +67,13 @@ func (a *App) openOperationDialog(ctx context.Context, kind ops.Kind, cfg ops.Co } switch kind { - case ops.KindReplace, ops.KindRemove: - target := "Replace target" - if kind == ops.KindRemove { - target = "Remove target" - } - text(target, cfg.Target, func(v string) { cfg.Target = v }) - if kind == ops.KindReplace { - text("Replacement", cfg.Replacement, func(v string) { cfg.Replacement = v }) - } + case ops.KindReplace: + text("Replace target", cfg.Target, func(v string) { cfg.Target = v }) + text("Replacement", cfg.Replacement, func(v string) { cfg.Replacement = v }) number("Limit (0 = all)", cfg.Limit, acceptInt, func(v string) { cfg.Limit = v }) check("From start", cfg.FromStart, func(v bool) { cfg.FromStart = v }) check("Case sensitive", cfg.CaseSensitive, func(v bool) { cfg.CaseSensitive = v }) - check(strings.ToLower(string(kind))+" target is regex", cfg.Regex, func(v bool) { cfg.Regex = v }) + check("Replace target is regex", cfg.Regex, func(v bool) { cfg.Regex = v }) check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v }) case ops.KindInsert: @@ -202,9 +196,7 @@ func (a *App) confirmApply(ctx context.Context) { }) modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageConfirm) })) - a.dialogFocus[pageConfirm] = a.app.GetFocus() - a.pages.AddPage(pageConfirm, modal, true, true) - a.app.SetFocus(modal) + a.showOverlay(pageConfirm, modal, modal) } // applyRename performs the rename on disk and reloads the folder. @@ -230,9 +222,7 @@ func (a *App) showMessage(title, body string) { SetDoneFunc(func(int, string) { a.closeDialog(pageMessage) }) modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageMessage) })) - a.dialogFocus[pageMessage] = a.app.GetFocus() - a.pages.AddPage(pageMessage, modal, true, true) - a.app.SetFocus(modal) + a.showOverlay(pageMessage, modal, modal) } // helpText lists every key binding of the interface. @@ -292,9 +282,44 @@ func dismissOnEscape(close func()) func(*tcell.EventKey) *tcell.EventKey { // showDialog puts p on top of the main layout as a centred floating window. func (a *App) showDialog(name string, p tview.Primitive, width, height int) { + a.showOverlay(name, p, center(p, width, height)) +} + +// showOverlay adds a full-screen modal layer and focuses its interactive +// content. Mouse events not handled by the content are consumed by the layer +// instead of falling through to the main page. +func (a *App) showOverlay(name string, focus, layout tview.Primitive) { a.dialogFocus[name] = a.app.GetFocus() - a.pages.AddPage(name, center(p, width, height), true, true) - a.app.SetFocus(p) + a.pages.AddPage(name, newFocusTrap(layout, focus), true, true) + a.app.SetFocus(focus) +} + +// focusTrap is a modal primitive which delegates rendering and input to its +// layout while preventing unhandled mouse events from reaching lower pages. +type focusTrap struct { + tview.Primitive + focus tview.Primitive +} + +// newFocusTrap returns a modal wrapper around layout. +func newFocusTrap(layout, focus tview.Primitive) *focusTrap { + return &focusTrap{Primitive: layout, focus: focus} +} + +// MouseHandler forwards events to the overlay and consumes any event it does +// not handle. Clicking outside the content restores focus inside the overlay. +func (t *focusTrap) MouseHandler() func(tview.MouseAction, *tcell.EventMouse, func(tview.Primitive)) (bool, tview.Primitive) { + return func(action tview.MouseAction, event *tcell.EventMouse, setFocus func(tview.Primitive)) (bool, tview.Primitive) { + if handler := t.Primitive.MouseHandler(); handler != nil { + if consumed, capture := handler(action, event, setFocus); consumed { + return true, capture + } + } + if action == tview.MouseLeftDown && !t.focus.HasFocus() { + setFocus(t.focus) + } + return true, nil + } } // closeDialog removes a floating window and restores the previous focus.