remove operação remove, corrige bug de focus

This commit is contained in:
2026-09-08 11:16:15 -03:00
parent bf46c56b36
commit 94833d6f43
5 changed files with 128 additions and 38 deletions
+9 -15
View File
@@ -18,7 +18,6 @@ type Kind string
// The supported operation kinds. // The supported operation kinds.
const ( const (
KindReplace Kind = "Replace" KindReplace Kind = "Replace"
KindRemove Kind = "Remove"
KindInsert Kind = "Insert" KindInsert Kind = "Insert"
KindIncrement Kind = "Increment" KindIncrement Kind = "Increment"
KindLower Kind = "To lowercase" KindLower Kind = "To lowercase"
@@ -29,7 +28,6 @@ const (
// Kinds lists every supported operation kind, in menu order. // Kinds lists every supported operation kind, in menu order.
var Kinds = []Kind{ var Kinds = []Kind{
KindReplace, KindReplace,
KindRemove,
KindInsert, KindInsert,
KindIncrement, KindIncrement,
KindLower, KindLower,
@@ -48,9 +46,9 @@ const ToLast = "to-last"
type Config struct { type Config struct {
Kind Kind 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. 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. Text string // Insert: the text to insert.
Index string // Insert: character position, or ToLast for the end. Index string // Insert: character position, or ToLast for the end.
Prefix string // Increment: text placed before the counter. Prefix string // Increment: text placed before the counter.
@@ -59,9 +57,9 @@ type Config struct {
First string // Truncate: first character index, or ToLast. First string // Truncate: first character index, or ToLast.
Last string // Truncate: last 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. FromStart bool // Replace: apply Limit to the first matches instead of the last.
CaseSensitive bool // Replace, Remove: match letter case exactly. CaseSensitive bool // Replace: match letter case exactly.
Regex bool // Replace, Remove: treat Target as a regular expression. Regex bool // Replace: treat Target as a regular expression.
KeepBetween bool // Truncate: keep the selected range instead of removing it. KeepBetween bool // Truncate: keep the selected range instead of removing it.
PreserveExt bool // All kinds: leave the file extension untouched. 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. // cannot be parsed or a regular expression does not compile.
func New(cfg Config) (Operation, error) { func New(cfg Config) (Operation, error) {
switch cfg.Kind { switch cfg.Kind {
case KindReplace, KindRemove: case KindReplace:
return newSubstitute(cfg) return newSubstitute(cfg)
case KindInsert: case KindInsert:
return newInsert(cfg) return newInsert(cfg)
@@ -185,8 +183,8 @@ func compileTarget(cfg Config) (*regexp.Regexp, error) {
return re, nil return re, nil
} }
// substituteOp implements both [KindReplace] and [KindRemove]; a removal is a // substituteOp implements [KindReplace]. Setting the replacement to an empty
// replacement with empty text. // string removes matching text.
type substituteOp struct { type substituteOp struct {
cfg Config cfg Config
re *regexp.Regexp re *regexp.Regexp
@@ -253,11 +251,7 @@ func (o *substituteOp) substitute(s string) string {
func (o *substituteOp) Summary() string { func (o *substituteOp) Summary() string {
var b strings.Builder var b strings.Builder
if o.cfg.Kind == KindRemove { fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement)
fmt.Fprintf(&b, "Remove %q", o.cfg.Target)
} else {
fmt.Fprintf(&b, "Replace %q with %q", o.cfg.Target, o.cfg.Replacement)
}
flags := []string{} flags := []string{}
if o.limit > 0 { if o.limit > 0 {
where := "last" where := "last"
+23 -4
View File
@@ -103,7 +103,7 @@ func TestReplace(t *testing.T) {
} }
} }
func TestRemove(t *testing.T) { func TestReplaceWithEmptyReplacementRemovesMatches(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
cfg func(c *ops.Config) cfg func(c *ops.Config)
@@ -134,7 +134,7 @@ func TestRemove(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
cfg := ops.DefaultConfig(ops.KindRemove) cfg := ops.DefaultConfig(ops.KindReplace)
tt.cfg(&cfg) tt.cfg(&cfg)
if got := apply(t, cfg, tt.in, 0); got != tt.want { if got := apply(t, cfg, tt.in, 0); got != tt.want {
t.Errorf("Apply(%q) = %q, want %q", tt.in, 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", 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", want: "must not be negative",
}, },
{ {
@@ -321,7 +321,7 @@ func TestNewRejectsBadInput(t *testing.T) {
} }
func TestPreviewAppliesOperationsInOrder(t *testing.T) { func TestPreviewAppliesOperationsInOrder(t *testing.T) {
remove := ops.DefaultConfig(ops.KindRemove) remove := ops.DefaultConfig(ops.KindReplace)
remove.Target = "IMG_" remove.Target = "IMG_"
lower := ops.DefaultConfig(ops.KindUpper) lower := ops.DefaultConfig(ops.KindUpper)
increment := ops.DefaultConfig(ops.KindIncrement) 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])
}
}
}
+5 -1
View File
@@ -272,7 +272,11 @@ func (a *App) buildControls(ctx context.Context) {
func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey { func (a *App) globalKeys(ctx context.Context) func(*tcell.EventKey) *tcell.EventKey {
return func(event *tcell.EventKey) *tcell.EventKey { return func(event *tcell.EventKey) *tcell.EventKey {
if name, _ := a.pages.GetFrontPage(); name != pageMain { 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 return event
} }
switch event.Key() { switch event.Key() {
+48
View File
@@ -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) { func TestAppRemovesFilesAndOperations(t *testing.T) {
dir := makeDir(t, "one.txt", "two.txt") dir := makeDir(t, "one.txt", "two.txt")
app, screen, onLoop := startApp(t, dir) app, screen, onLoop := startApp(t, dir)
+43 -18
View File
@@ -67,19 +67,13 @@ func (a *App) openOperationDialog(ctx context.Context, kind ops.Kind, cfg ops.Co
} }
switch kind { switch kind {
case ops.KindReplace, ops.KindRemove: case ops.KindReplace:
target := "Replace target" text("Replace target", cfg.Target, func(v string) { cfg.Target = v })
if kind == ops.KindRemove { text("Replacement", cfg.Replacement, func(v string) { cfg.Replacement = v })
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 })
}
number("Limit (0 = all)", cfg.Limit, acceptInt, func(v string) { cfg.Limit = 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("From start", cfg.FromStart, func(v bool) { cfg.FromStart = v })
check("Case sensitive", cfg.CaseSensitive, func(v bool) { cfg.CaseSensitive = 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 }) check("Preserve extension", cfg.PreserveExt, func(v bool) { cfg.PreserveExt = v })
case ops.KindInsert: case ops.KindInsert:
@@ -202,9 +196,7 @@ func (a *App) confirmApply(ctx context.Context) {
}) })
modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageConfirm) })) modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageConfirm) }))
a.dialogFocus[pageConfirm] = a.app.GetFocus() a.showOverlay(pageConfirm, modal, modal)
a.pages.AddPage(pageConfirm, modal, true, true)
a.app.SetFocus(modal)
} }
// applyRename performs the rename on disk and reloads the folder. // 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) }) SetDoneFunc(func(int, string) { a.closeDialog(pageMessage) })
modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageMessage) })) modal.SetInputCapture(dismissOnEscape(func() { a.closeDialog(pageMessage) }))
a.dialogFocus[pageMessage] = a.app.GetFocus() a.showOverlay(pageMessage, modal, modal)
a.pages.AddPage(pageMessage, modal, true, true)
a.app.SetFocus(modal)
} }
// helpText lists every key binding of the interface. // 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. // 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) { 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.dialogFocus[name] = a.app.GetFocus()
a.pages.AddPage(name, center(p, width, height), true, true) a.pages.AddPage(name, newFocusTrap(layout, focus), true, true)
a.app.SetFocus(p) 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. // closeDialog removes a floating window and restores the previous focus.