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.
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"
+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 {
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])
}
}
}
+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 {
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() {
+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) {
dir := makeDir(t, "one.txt", "two.txt")
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 {
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.