From 8cf222758b96070b8ed5dbc12d54d853c36316df Mon Sep 17 00:00:00 2001 From: Rustam Gilyazov <16064414+rusq@users.noreply.github.com> Date: Sun, 27 Oct 2024 14:10:03 +1000 Subject: [PATCH] wizard model draft --- cmd/slackdump/internal/diag/wizdebug.go | 13 +- cmd/slackdump/internal/ui/cfgui/cfgui.go | 31 +--- cmd/slackdump/internal/ui/cfgui/keymap.go | 29 ++++ cmd/slackdump/internal/ui/cfgui/model.go | 111 +++++++++----- cmd/slackdump/internal/ui/cfgui/style.go | 48 ++++++ .../internal/ui/dumpui/focusmodel.go | 11 ++ cmd/slackdump/internal/ui/dumpui/keymap.go | 23 +++ cmd/slackdump/internal/ui/dumpui/menuitem.go | 20 +++ cmd/slackdump/internal/ui/dumpui/model.go | 140 ++++++++---------- cmd/slackdump/internal/ui/dumpui/style.go | 45 ++++++ 10 files changed, 317 insertions(+), 154 deletions(-) create mode 100644 cmd/slackdump/internal/ui/cfgui/keymap.go create mode 100644 cmd/slackdump/internal/ui/cfgui/style.go create mode 100644 cmd/slackdump/internal/ui/dumpui/focusmodel.go create mode 100644 cmd/slackdump/internal/ui/dumpui/keymap.go create mode 100644 cmd/slackdump/internal/ui/dumpui/menuitem.go create mode 100644 cmd/slackdump/internal/ui/dumpui/style.go diff --git a/cmd/slackdump/internal/diag/wizdebug.go b/cmd/slackdump/internal/diag/wizdebug.go index 1e1ffa0a..5581499c 100644 --- a/cmd/slackdump/internal/diag/wizdebug.go +++ b/cmd/slackdump/internal/diag/wizdebug.go @@ -5,13 +5,15 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" ) var CmdWizDebug = &base.Command{ - UsageLine: "slackdump tools wizdebug", - Short: "run the wizard debug command", - Run: runWizDebug, + UsageLine: "slackdump tools wizdebug", + Short: "run the wizard debug command", + Run: runWizDebug, + PrintFlags: true, } func runWizDebug(ctx context.Context, cmd *base.Command, args []string) error { @@ -21,8 +23,9 @@ func runWizDebug(ctx context.Context, cmd *base.Command, args []string) error { Help: "Run the command", }, { - Name: "Global Configuration...", - Help: "Set global configuration options", + Name: "Global Configuration...", + Help: "Set global configuration options", + Model: cfgui.NewConfigUI(cfgui.DefaultStyle(), cfgui.GlobalConfig).(dumpui.FocusModel), // TODO: filthy cast }, { Name: "Local Configuration...", diff --git a/cmd/slackdump/internal/ui/cfgui/cfgui.go b/cmd/slackdump/internal/ui/cfgui/cfgui.go index beb7ce06..14f78d55 100644 --- a/cmd/slackdump/internal/ui/cfgui/cfgui.go +++ b/cmd/slackdump/internal/ui/cfgui/cfgui.go @@ -4,7 +4,6 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) // Global initialises and runs the configuration UI. @@ -14,34 +13,12 @@ func Global(ctx context.Context) error { return err } +func GlobalConfig() Configuration { + return globalConfig() +} + func Local(ctx context.Context, cfgFn func() Configuration) error { p := tea.NewProgram(NewConfigUI(DefaultStyle(), cfgFn)) _, err := p.Run() return err } - -func NewConfigUI(sty Style, cfgFn func() Configuration) tea.Model { - end := 0 - for _, group := range cfgFn() { - end += len(group.Params) - } - end-- - return configmodel{ - cfgFn: cfgFn, - end: end, - Style: sty, - } -} - -func DefaultStyle() Style { - return Style{ - Border: ui.DefaultTheme().Focused.Border, - Title: ui.DefaultTheme().Focused.Options.Section, - Description: ui.DefaultTheme().Focused.Description, - Name: ui.DefaultTheme().Focused.Options.Name, - ValueEnabled: ui.DefaultTheme().Focused.Options.EnabledValue, - ValueDisabled: ui.DefaultTheme().Focused.Options.DisabledValue, - SelectedName: ui.DefaultTheme().Focused.Options.SelectedName, - Cursor: ui.DefaultTheme().Focused.Cursor, - } -} diff --git a/cmd/slackdump/internal/ui/cfgui/keymap.go b/cmd/slackdump/internal/ui/cfgui/keymap.go new file mode 100644 index 00000000..d0b40548 --- /dev/null +++ b/cmd/slackdump/internal/ui/cfgui/keymap.go @@ -0,0 +1,29 @@ +package cfgui + +import "github.com/charmbracelet/bubbles/key" + +type Keymap struct { + Up key.Binding + Down key.Binding + Home key.Binding + End key.Binding + Refresh key.Binding + Select key.Binding + Quit key.Binding +} + +func DefaultKeymap() *Keymap { + return &Keymap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), + Home: key.NewBinding(key.WithKeys("home"), key.WithHelp("home/end", "top/bottom")), + End: key.NewBinding(key.WithKeys("end")), + Refresh: key.NewBinding(key.WithKeys("f5", "ctrl+r"), key.WithHelp("f5", "refresh")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Quit: key.NewBinding(key.WithKeys("q", "esc", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +func (k *Keymap) Bindings() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Home, k.Refresh, k.Select, k.Quit} +} diff --git a/cmd/slackdump/internal/ui/cfgui/model.go b/cmd/slackdump/internal/ui/cfgui/model.go index 6c4277f0..ad6665e6 100644 --- a/cmd/slackdump/internal/ui/cfgui/model.go +++ b/cmd/slackdump/internal/ui/cfgui/model.go @@ -4,9 +4,9 @@ import ( "fmt" "strings" + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" - "github.com/charmbracelet/lipgloss" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/updaters" ) @@ -21,29 +21,37 @@ const ( notFound = -1 ) -type Style struct { - Border lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - Name lipgloss.Style - ValueEnabled lipgloss.Style - ValueDisabled lipgloss.Style - SelectedName lipgloss.Style - Cursor lipgloss.Style -} - type configmodel struct { finished bool - cfgFn func() Configuration + focused bool cursor int - end int - Style Style + last int + state state + help help.Model + + style *Style + keymap *Keymap child tea.Model - state state + cfgFn func() Configuration +} + +func NewConfigUI(sty *Style, cfgFn func() Configuration) tea.Model { + end := 0 + for _, group := range cfgFn() { + end += len(group.Params) + } + end-- + return &configmodel{ + cfgFn: cfgFn, + last: end, + keymap: DefaultKeymap(), + style: sty, + help: help.New(), + } } -func (m configmodel) Init() tea.Cmd { +func (m *configmodel) Init() tea.Cmd { return nil } @@ -55,7 +63,11 @@ const ( inline ) -func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + if !m.focused { + return m, nil + } + var cmds []tea.Cmd if _, ok := msg.(updaters.WMClose); m.child != nil && !ok && m.state != selecting { @@ -71,28 +83,28 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.child = nil cmds = append(cmds, refreshCfgCmd) case tea.KeyMsg: - switch msg.String() { - case "up", "k": + switch { + case key.Matches(msg, m.keymap.Up): if m.cursor > 0 { m.cursor-- } else { // wrap around - m.cursor = m.end + m.cursor = m.last } - case "down", "j": - if m.cursor < m.end { + case key.Matches(msg, m.keymap.Down): + if m.cursor < m.last { m.cursor++ } else { // wrap around m.cursor = 0 } - case "home": + case key.Matches(msg, m.keymap.Home): m.cursor = 0 - case "end": - m.cursor = m.end - case "f5": + case key.Matches(msg, m.keymap.End): + m.cursor = m.last + case key.Matches(msg, m.keymap.Refresh): cmds = append(cmds, refreshCfgCmd) - case "enter": + case key.Matches(msg, m.keymap.Select): i, j := locateParam(m.cfgFn(), m.cursor) if i == notFound || j == notFound { return m, nil @@ -106,7 +118,7 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.child = params.Updater cmds = append(cmds, m.child.Init()) } - case "q", "esc", "ctrl+c": + case key.Matches(msg, m.keymap.Quit): // child is active if m.state != selecting { break @@ -119,41 +131,59 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -func (m configmodel) View() string { +func (m *configmodel) SetFocus(b bool) { + m.focused = b +} + +func (m *configmodel) IsFocused() bool { + return m.focused +} + +func (m *configmodel) Reset() { + m.finished = false + m.state = selecting + m.child = nil +} + +func (m *configmodel) View() string { if m.finished { return "" } + var sty = m.style.Focused + if !m.focused { + sty = m.style.Blurred + } if m.child != nil && len(m.child.View()) > 0 && m.state == editing { return m.child.View() } - return ui.DefaultTheme().Focused.Border.Render(m.view()) + return sty.Border.Render(m.view(sty)) } -func (m configmodel) view() string { +func (m *configmodel) view(sty StyleSet) string { var buf strings.Builder line := 0 descr := "" for i, group := range m.cfgFn() { - buf.WriteString(alignGroup + m.Style.Title.Render(group.Name)) + buf.WriteString(alignGroup + sty.Title.Render(group.Name)) buf.WriteString("\n") keyLen, valLen := group.maxLen() for j, param := range group.Params { selected := line == m.cursor if selected { - buf.WriteString(m.Style.Cursor.Render(cursorChar)) + buf.WriteString(sty.Cursor.Render(cursorChar)) descr = m.cfgFn()[i].Params[j].Description } else { buf.WriteString(" ") } - valfmt := m.Style.ValueDisabled + valfmt := sty.ValueDisabled if param.Updater != nil { - valfmt = m.Style.ValueEnabled + valfmt = sty.ValueEnabled } - namefmt := m.Style.Name + namefmt := sty.Name if selected { - namefmt = m.Style.SelectedName + namefmt = sty.SelectedName } fmt.Fprintf(&buf, alignParam+namefmt.Render(fmt.Sprintf("% *s", keyLen, param.Name))+" ") if selected && m.state == inline { @@ -164,7 +194,8 @@ func (m configmodel) view() string { line++ } } - buf.WriteString(alignGroup + m.Style.Description.Render(descr)) + buf.WriteString(alignGroup + sty.Description.Render(descr) + "\n") + buf.WriteString(m.help.ShortHelpView(m.keymap.Bindings())) return buf.String() } diff --git a/cmd/slackdump/internal/ui/cfgui/style.go b/cmd/slackdump/internal/ui/cfgui/style.go new file mode 100644 index 00000000..e440e8d4 --- /dev/null +++ b/cmd/slackdump/internal/ui/cfgui/style.go @@ -0,0 +1,48 @@ +package cfgui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +type Style struct { + Focused StyleSet + Blurred StyleSet +} + +type StyleSet struct { + Border lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + Name lipgloss.Style + ValueEnabled lipgloss.Style + ValueDisabled lipgloss.Style + SelectedName lipgloss.Style + Cursor lipgloss.Style +} + +func DefaultStyle() *Style { + t := ui.DefaultTheme() + return &Style{ + Focused: StyleSet{ + Border: t.Focused.Border, + Title: t.Focused.Options.Section, + Description: t.Focused.Description, + Name: t.Focused.Options.Name, + ValueEnabled: t.Focused.Options.EnabledValue, + ValueDisabled: t.Focused.Options.DisabledValue, + SelectedName: t.Focused.Options.SelectedName, + Cursor: t.Focused.Cursor, + }, + Blurred: StyleSet{ + Border: t.Blurred.Border, + Title: t.Blurred.Options.Section, + Description: t.Blurred.Description, + Name: t.Blurred.Options.Name, + ValueEnabled: t.Blurred.Options.EnabledValue, + ValueDisabled: t.Blurred.Options.DisabledValue, + SelectedName: t.Blurred.Options.SelectedName, + Cursor: t.Blurred.Cursor, + }, + } +} diff --git a/cmd/slackdump/internal/ui/dumpui/focusmodel.go b/cmd/slackdump/internal/ui/dumpui/focusmodel.go new file mode 100644 index 00000000..b531d8d8 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/focusmodel.go @@ -0,0 +1,11 @@ +package dumpui + +import tea "github.com/charmbracelet/bubbletea" + +type FocusModel interface { + tea.Model + SetFocus(bool) + IsFocused() bool + // Reset should reset the model to its initial state. + Reset() +} diff --git a/cmd/slackdump/internal/ui/dumpui/keymap.go b/cmd/slackdump/internal/ui/dumpui/keymap.go new file mode 100644 index 00000000..85218729 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/keymap.go @@ -0,0 +1,23 @@ +package dumpui + +import "github.com/charmbracelet/bubbles/key" + +type Keymap struct { + Up key.Binding + Down key.Binding + Select key.Binding + Quit key.Binding +} + +func DefaultKeymap() *Keymap { + return &Keymap{ + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), + Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), + Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), + } +} + +func (k *Keymap) Bindings() []key.Binding { + return []key.Binding{k.Up, k.Down, k.Select, k.Quit} +} diff --git a/cmd/slackdump/internal/ui/dumpui/menuitem.go b/cmd/slackdump/internal/ui/dumpui/menuitem.go new file mode 100644 index 00000000..1f77feb7 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/menuitem.go @@ -0,0 +1,20 @@ +package dumpui + +// MenuItem is an item in a menu. +type MenuItem struct { + // ID is an arbitrary ID, up to caller. + ID string + // Separator is a flag that determines whether the item is a separator or not. + Separator bool + // Name is the name of the Item, will be displayed in the menu. + Name string + // Help is the help text for the item, that will be shown when the + // item is highlighted. + Help string + // Model is any model that should be displayed when the item is selected, + // or executed when the user presses enter. + Model FocusModel + // IsDisabled determines whether the item is disabled or not. It should + // complete in reasonable time, as it is called on every render. + IsDisabled func() bool // when to enable the item +} diff --git a/cmd/slackdump/internal/ui/dumpui/model.go b/cmd/slackdump/internal/ui/dumpui/model.go index 588897e4..b71c35a0 100644 --- a/cmd/slackdump/internal/ui/dumpui/model.go +++ b/cmd/slackdump/internal/ui/dumpui/model.go @@ -7,12 +7,16 @@ import ( "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) type Model struct { - Title string - Items []MenuItem + // Selected will be set to the selected item from the items. + Selected MenuItem + + title string + items []MenuItem finishing bool focused bool Style *Style @@ -23,65 +27,10 @@ type Model struct { cursor int } -type Style struct { - Focused StyleSet - Blurred StyleSet -} - -type StyleSet struct { - Border lipgloss.Style - Title lipgloss.Style - Description lipgloss.Style - Cursor lipgloss.Style - Item lipgloss.Style - ItemSelected lipgloss.Style - ItemDisabled lipgloss.Style -} - -func DefaultStyle() *Style { - t := ui.DefaultTheme() - return &Style{ - Focused: StyleSet{ - Border: t.Focused.Border, - Title: t.Focused.Title, - Description: t.Focused.Description, - Cursor: t.Focused.Cursor, - Item: t.Focused.Text, - ItemSelected: t.Focused.Selected, - ItemDisabled: t.Blurred.Text, - }, - Blurred: StyleSet{ - Border: t.Blurred.Border, - Title: t.Blurred.Title, - Description: t.Blurred.Description, - Cursor: t.Blurred.Cursor, - Item: t.Blurred.Text, - ItemSelected: t.Blurred.Selected, - ItemDisabled: t.Blurred.Text, - }, - } -} - -type Keymap struct { - Up key.Binding - Down key.Binding - Select key.Binding - Quit key.Binding -} - -func DefaultKeymap() *Keymap { - return &Keymap{ - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓", "down")), - Select: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit")), - Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), - } -} - func NewModel(title string, items []MenuItem) *Model { return &Model{ - Title: title, - Items: items, + title: title, + items: items, Style: DefaultStyle(), Keymap: DefaultKeymap(), help: help.New(), @@ -95,6 +44,24 @@ func (m *Model) Init() tea.Cmd { } func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + child := m.items[m.cursor].Model + cfg.Log.Debugf("msg: %v, child is nil? %t", msg, child == nil) + + if child != nil && child.IsFocused() { + ch, cmd := child.Update(msg) + m.items[m.cursor].Model = ch.(FocusModel) + if cmd != nil && cmd() != nil { + if _, ok := cmd().(tea.QuitMsg); ok { + // if child quit, we need to set focus back to the menu. + m.SetFocus(true) + child.SetFocus(false) + child.Reset() + return m, nil + } + } + return m, cmd + } + switch msg := msg.(type) { case tea.KeyMsg: switch { @@ -106,25 +73,33 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.cursor > 0 { m.cursor-- } - if !m.Items[m.cursor].Disabled && !m.Items[m.cursor].Separator { + if !m.items[m.cursor].Separator { break } } case key.Matches(msg, m.Keymap.Down): for { - if m.cursor < len(m.Items)-1 { + if m.cursor < len(m.items)-1 { m.cursor++ } - if !m.Items[m.cursor].Disabled && !m.Items[m.cursor].Separator { + if !m.items[m.cursor].Separator { break } } case key.Matches(msg, m.Keymap.Select): - if m.Items[m.cursor].Disabled || m.Items[m.cursor].Separator { + dfn := m.items[m.cursor].IsDisabled + if m.items[m.cursor].Separator || (dfn != nil && dfn()) { + return m, nil + } + m.Selected = m.items[m.cursor] + + if child := m.items[m.cursor].Model; child != nil { + m.SetFocus(false) + child.SetFocus(true) return m, nil } + m.finishing = true - // TODO: return the selected choice return m, tea.Quit } } @@ -135,10 +110,21 @@ func (m *Model) SetFocus(b bool) { m.focused = b } +func (m *Model) IsFocused() bool { + return m.focused +} + func (m *Model) View() string { if m.finishing { return "" } + if m.items[m.cursor].Model != nil { + return lipgloss.JoinHorizontal(lipgloss.Top, m.view(), m.items[m.cursor].Model.View()) + } + return m.view() +} + +func (m *Model) view() string { var b strings.Builder sty := m.Style.Focused @@ -147,43 +133,33 @@ func (m *Model) View() string { } p := b.WriteString // Header - p(sty.Title.Render(m.Title) + "\n") - p(sty.Description.Render(m.Items[m.cursor].Help)) + p(sty.Title.Render(m.title) + "\n") + p(sty.Description.Render(m.items[m.cursor].Help)) const ( padding = " " pointer = "> " ) - for i, itm := range m.Items { + for i, itm := range m.items { p("\n") if itm.Separator { p(padding + ui.MenuSeparator) continue } - if itm.Disabled { + + if itm.IsDisabled != nil && itm.IsDisabled() { p(sty.ItemDisabled.Render(padding + itm.Name)) continue } if i == m.cursor { p(sty.Cursor.Render(pointer) + sty.ItemSelected.Render(itm.Name)) } else { - if itm.Disabled { - p(sty.ItemDisabled.Render(padding + itm.Name)) - } else { - p(sty.Item.Render(padding + itm.Name)) - } + p(sty.Item.Render(padding + itm.Name)) } } - b.WriteString("\n\n" + m.footer()) + b.WriteString("\n" + m.footer()) return sty.Border.Render(b.String()) } func (m *Model) footer() string { - return m.help.ShortHelpView([]key.Binding{m.Keymap.Up, m.Keymap.Down, m.Keymap.Select, m.Keymap.Quit}) -} - -type MenuItem struct { - Name string - Help string - Disabled bool - Separator bool + return m.help.ShortHelpView(m.Keymap.Bindings()) } diff --git a/cmd/slackdump/internal/ui/dumpui/style.go b/cmd/slackdump/internal/ui/dumpui/style.go new file mode 100644 index 00000000..17ccc8a9 --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/style.go @@ -0,0 +1,45 @@ +package dumpui + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" +) + +type Style struct { + Focused StyleSet + Blurred StyleSet +} + +type StyleSet struct { + Border lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + Cursor lipgloss.Style + Item lipgloss.Style + ItemSelected lipgloss.Style + ItemDisabled lipgloss.Style +} + +func DefaultStyle() *Style { + t := ui.DefaultTheme() + return &Style{ + Focused: StyleSet{ + Border: t.Focused.Border, + Title: t.Focused.Title, + Description: t.Focused.Description, + Cursor: t.Focused.Cursor, + Item: t.Focused.Text, + ItemSelected: t.Focused.Selected, + ItemDisabled: t.Blurred.Text, + }, + Blurred: StyleSet{ + Border: t.Blurred.Border, + Title: t.Blurred.Title, + Description: t.Blurred.Description, + Cursor: t.Blurred.Cursor, + Item: t.Blurred.Text, + ItemSelected: t.Blurred.Selected, + ItemDisabled: t.Blurred.Text, + }, + } +}