remove operação remove, corrige bug de focus
This commit is contained in:
+8
-14
@@ -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, "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{}
|
flags := []string{}
|
||||||
if o.limit > 0 {
|
if o.limit > 0 {
|
||||||
where := "last"
|
where := "last"
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+42
-17
@@ -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 {
|
|
||||||
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 })
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user