Files
MujicaUI/rename_inline.go
T

198 lines
5.7 KiB
Go

package mujica
import (
"log/slog"
"path"
"strings"
"github.com/egoist/mygo/ui"
"mujicaui/theme"
)
// RenameInlineOptions configure a RenameInline.
type RenameInlineOptions struct {
// Validate returns an error message, or empty for acceptance.
Validate func(name string) string
// Folder selects the whole name: folders have no extension.
Folder bool
Disabled bool
}
// RenameInlineResult is a RenameInline and its outcome in this frame.
type RenameInlineResult struct {
Element *ui.Element
// Renamed reports a saved name; Canceled reports an abandoned edit.
Renamed, Canceled bool
}
// renameState is one editing session.
type renameState struct {
active bool
draft string
// gen rebuilds the editor with its caret at the end.
gen int
// tail appends text next frame behind the caret.
tail string
shown bool
stemSel bool
focused bool
err string
folder bool
}
// renameStem is the part of name before its extension.
func renameStem(name string, folder bool) string {
ext := path.Ext(name)
if folder || ext == name || ext == "" {
return name
}
return strings.TrimSuffix(name, ext)
}
// RenameInline edits names with validation and initial stem replacement.
// Enter validates; Escape cancels; blur confirms valid names.
func RenameInline(c *ui.Context, name *string, editing *bool, opts RenameInlineOptions) RenameInlineResult {
if name == nil || editing == nil {
panic("mujica: RenameInline needs a name and editing")
}
var res RenameInlineResult
res.Element = ui.Row(c).MinWidth(0).MinHeight(theme.MinHitSize).AlignItems(ui.Center).Disabled(opts.Disabled)
st := ui.Local(res.Element, "rename_inline", func() renameState { return renameState{} })
if res.Element.IsDisabled() && *editing {
*editing = false
res.Canceled = st.active
st.active = false
}
res.Element.Children(func() {
if !*editing {
st.active = false
t := ui.Text(c, *name).SingleLine().MinWidth(0)
if t.DoubleClicked() && !res.Element.IsDisabled() {
*editing = true
c.Invalidate()
}
return
}
stem := renameStem(*name, opts.Folder)
if !st.active {
// Start with the stem and its caret at the end.
*st = renameState{active: true, draft: stem, gen: st.gen + 1, tail: (*name)[len(stem):], stemSel: true, folder: opts.Folder}
slog.Debug("mujica: rename_inline start")
}
switch {
case st.tail != "" && st.shown:
st.draft, st.tail, st.shown = st.draft+st.tail, "", false
case st.tail != "":
// Show the head for one frame to place its caret.
st.shown = true
c.Invalidate()
}
finish := func(ok bool) {
if ok {
if why := renameCheck(c, st.draft, opts.Validate); why != "" {
st.err = why
return
}
res.Renamed = st.draft != *name
*name = st.draft
} else {
res.Canceled = true
}
*editing, st.active = false, false
slog.Debug("mujica: rename_inline end", "renamed", res.Renamed)
}
ui.Box(c).Key(st.gen).Grow(1).MinWidth(0).Children(func() {
in := TextInput(c, &st.draft, TextInputOptions{Label: msg(c, "rename_inline.label"), Error: st.err})
in.Element.FillWidth()
in.Input.AutoFocus()
if in.Changed() {
st.err = ""
renameTyped(st, *name, opts.Folder)
}
renameSelection(c, in.Input, st)
switch {
case in.Input.Shortcut(0, ui.KeyEscape):
finish(false)
case in.Submitted():
finish(true)
case in.Input.Focused():
st.focused = true
case st.focused && st.tail == "":
// Confirm valid names when focus leaves.
finish(renameCheck(c, st.draft, opts.Validate) == "")
}
})
})
return res
}
func renameCheck(c *ui.Context, name string, validate func(string) string) string {
if strings.TrimSpace(name) == "" {
return msg(c, "rename_inline.empty")
}
if validate != nil {
return validate(name)
}
return ""
}
// renameSelection marks the stem for replacement on initial input.
func renameSelection(c *ui.Context, in *ui.Element, st *renameState) {
k := Tokens(c)
stem := renameStem(st.draft, st.folder)
if st.stemSel && st.tail == "" {
w, _ := c.MeasureText(0, ui.Span{Text: stem, Size: FontSize(c, theme.BodySize)})
in.Draw(func(p *ui.Painter, r ui.Rect) {
p.Fill(ui.Rect{X: r.X, Y: r.Y + 2, W: w, H: r.H - 4}, k.Selection, 2)
})
}
in.HandleInput(func(ev ui.InputEvent) bool {
if !st.stemSel || in.IsDisabled() {
return false
}
switch {
case ev.Kind == ui.InputKeyDown && ev.Mods == 0:
switch ev.Key {
case ui.KeyBackspace, ui.KeyDelete:
renameReplace(st, "")
return true
case ui.KeyLeft, ui.KeyHome, ui.KeyUp:
// An empty head clamps the caret to the start.
st.draft, st.tail, st.stemSel, st.shown = "", st.draft, false, false
return true
case ui.KeyRight:
st.stemSel = false
return true
}
}
if ev.Kind == ui.InputPointerDown || ev.Kind == ui.InputKeyDown &&
(ev.Key == ui.KeyLeft || ev.Key == ui.KeyRight || ev.Key == ui.KeyHome || ev.Key == ui.KeyEnd || ev.Key == ui.KeyUp || ev.Key == ui.KeyDown || ev.Key == ui.KeyA && ev.Mods&ui.Cmd != 0) {
st.stemSel = false
}
return false
})
}
// renameReplace substitutes the stem and rebuilds the caret.
func renameReplace(st *renameState, text string) {
ext := st.draft[len(renameStem(st.draft, st.folder)):]
st.draft, st.tail, st.gen, st.stemSel, st.shown = text, ext, st.gen+1, false, false
}
// renameTyped replaces the stem with newly inserted text.
func renameTyped(st *renameState, name string, folder bool) {
if !st.stemSel {
return
}
stem := renameStem(name, folder)
ext := name[len(stem):]
st.stemSel = false
if strings.HasPrefix(st.draft, stem) && strings.HasSuffix(st.draft, ext) && len(st.draft) >= len(name) {
typed := st.draft[len(stem) : len(st.draft)-len(ext)]
st.draft = stem + ext
renameReplace(st, typed)
slog.Debug("mujica: rename_inline replace stem")
}
}