diff --git a/cmd/slackdump/internal/apiconfig/check.go b/cmd/slackdump/internal/apiconfig/check.go index 31fa7377..e8092000 100644 --- a/cmd/slackdump/internal/apiconfig/check.go +++ b/cmd/slackdump/internal/apiconfig/check.go @@ -9,8 +9,8 @@ import ( "github.com/charmbracelet/bubbles/viewport" 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/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/filemgr" ) @@ -62,12 +62,10 @@ func wizConfigCheck(ctx context.Context, cmd *base.Command, args []string) error f.Focus() f.ShowHelp = true f.Style = filemgr.Style{ - Normal: cfg.Theme.Focused.File, - Directory: cfg.Theme.Focused.Directory, - Inverted: lipgloss.NewStyle(). - Foreground(cfg.Theme.Focused.FocusedButton.GetForeground()). - Background(cfg.Theme.Focused.FocusedButton.GetBackground()), - Shaded: cfg.WizStyle.ShadedCursor, + Normal: ui.DefaultTheme().Focused.UnselectedFile, + Directory: ui.DefaultTheme().Focused.Directory, + Inverted: ui.DefaultTheme().Focused.SelectedFile, + Shaded: ui.DefaultTheme().Focused.DisabledFile, } vp := viewport.New(80-filemgr.Width, f.Height) vp.Style = lipgloss.NewStyle().Margin(0, 2) @@ -75,8 +73,8 @@ func wizConfigCheck(ctx context.Context, cmd *base.Command, args []string) error m := checkerModel{ files: f, view: vp, - FocusStyle: cfg.WizStyle.FocusedBorder, - BlurStyle: cfg.WizStyle.BlurredBorder, + FocusStyle: ui.DefaultTheme().Focused.Border, + BlurStyle: ui.DefaultTheme().Blurred.Border, } if _, err := tea.NewProgram(m).Run(); err != nil { diff --git a/cmd/slackdump/internal/archive/wizard.go b/cmd/slackdump/internal/archive/wizard.go index 1388de3e..2136d064 100644 --- a/cmd/slackdump/internal/archive/wizard.go +++ b/cmd/slackdump/internal/archive/wizard.go @@ -2,59 +2,16 @@ package archive import ( "context" - "strings" - "github.com/charmbracelet/huh" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "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/wizard" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" ) func archiveWizard(ctx context.Context, cmd *base.Command, args []string) error { - var ( - action string = "run" - ) - - menu := func() *huh.Form { - return huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Key("selection"). - Title("Archive Slackdump workspace"). - Options( - huh.NewOption("Run!", "run"), - huh.NewOption("Configure", "config"), - huh.NewOption("Show Config", "show"), - huh.NewOption(strings.Repeat("-", 10), ""), - huh.NewOption("Exit archive wizard", "exit"), - ).Value(&action), - ), - ).WithTheme(cfg.Theme).WithAccessible(cfg.AccessibleMode) - } - -LOOP: - for { - if err := menu().RunWithContext(ctx); err != nil { - return err - } - switch action { - case "exit": - break LOOP - case "config": - if err := wizard.Config(ctx); err != nil { - return err - } - case "show": - if err := cfgui.Show(ctx); err != nil { - return err - } - case "run": - if err := RunArchive(ctx, CmdArchive, nil); err != nil { - return err - } - } + w := &dumpui.Wizard{ + Title: "Archive Slackdump workspace", + Particulars: "Archive", + Cmd: cmd, } - - return nil + return w.Run(ctx) } diff --git a/cmd/slackdump/internal/cfg/wizard.go b/cmd/slackdump/internal/cfg/wizard.go deleted file mode 100644 index 95329567..00000000 --- a/cmd/slackdump/internal/cfg/wizard.go +++ /dev/null @@ -1,28 +0,0 @@ -package cfg - -import ( - "github.com/charmbracelet/huh" - "github.com/charmbracelet/lipgloss" -) - -var ( - Theme = huh.ThemeCharm() // Theme is the default Wizard theme. -) - -var ( - // https://gist.github.com/JBlond/2fea43a3049b38287e5e9cefc87b2124 - cDarkgray = lipgloss.Color("237") - cPurewhite = lipgloss.Color("255") -) - -type Style struct { - FocusedBorder lipgloss.Style - BlurredBorder lipgloss.Style - ShadedCursor lipgloss.Style -} - -var WizStyle = Style{ - FocusedBorder: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(Theme.Focused.Title.GetForeground()), - BlurredBorder: lipgloss.NewStyle().BorderStyle(lipgloss.RoundedBorder()).BorderForeground(Theme.Blurred.Description.GetForeground()), - ShadedCursor: lipgloss.NewStyle().Background(cDarkgray).Foreground(cPurewhite), -} diff --git a/cmd/slackdump/internal/dump/wizard.go b/cmd/slackdump/internal/dump/wizard.go index 07935c78..343c82af 100644 --- a/cmd/slackdump/internal/dump/wizard.go +++ b/cmd/slackdump/internal/dump/wizard.go @@ -4,12 +4,14 @@ import ( "context" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" ) func WizDump(ctx context.Context, cmd *base.Command, args []string) error { - // ask for the list of channels - // ask for from and to dates - // ask name template - // ask filesystem - panic("not implemented") + w := dumpui.Wizard{ + Title: "Dump Slackdump channels", + Particulars: "Dump", + Cmd: cmd, + } + return w.Run(ctx) } diff --git a/cmd/slackdump/internal/export/wizard.go b/cmd/slackdump/internal/export/wizard.go index 49723e60..bcccf4bf 100644 --- a/cmd/slackdump/internal/export/wizard.go +++ b/cmd/slackdump/internal/export/wizard.go @@ -4,65 +4,14 @@ import ( "context" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/dumpui" ) func wizExport(ctx context.Context, cmd *base.Command, args []string) error { - // options.Logger = logger.FromContext(ctx) - // prov, err := auth.FromContext(ctx) - // if err != nil { - // return err - // } - // // ask for the list - // list, err := ask.ConversationList("Enter conversations to export (optional)?") - // if err != nil { - // return err - // } - // options.List = list - - // // ask if user wants time range - // options.Oldest, options.Latest, err = ask.MaybeTimeRange() - // if err != nil { - // return err - // } - - // // ask for the type - // exportType, err := ask.ExportType() - // if err != nil { - // return err - // } else { - // options.Type = exportType - // } - - // if wantExportToken, err := ui.Confirm("Do you want to specify an export token for attachments?", false); err != nil { - // return err - // } else if wantExportToken { - // // ask for the export token - // exportToken, err := ui.String("Export token", "Enter the export token, that will be appended to each of the attachment URLs.") - // if err != nil { - // return err - // } - // options.ExportToken = exportToken - // } - - // // ask for the save location - // baseLoc, err := ui.FileSelector("Output ZIP or Directory name", "Enter the name of the ZIP or directory to save the export to.") - // if err != nil { - // return err - // } - // fsa, err := fsadapter.New(baseLoc) - // if err != nil { - // return err - // } - // defer fsa.Close() - - // sess, err := slackdump.New(ctx, prov, slackdump.WithFilesystem(fsa), slackdump.WithLogger(options.Logger)) - // if err != nil { - // return err - // } - // // TODO v3 - // exp := export.New(sess, fsa, options) - - // // run export - // return exp.Run(ctx) - return nil + w := &dumpui.Wizard{ + Title: "Export Slackdump workspace", + Particulars: "Export", + Cmd: cmd, + } + return w.Run(ctx) } diff --git a/cmd/slackdump/internal/ui/bubbles/datepicker/datepicker.go b/cmd/slackdump/internal/ui/bubbles/datepicker/datepicker.go new file mode 100644 index 00000000..f96ca3d7 --- /dev/null +++ b/cmd/slackdump/internal/ui/bubbles/datepicker/datepicker.go @@ -0,0 +1,362 @@ +// Package datepicker provides a bubble tea component for viewing and selecting +// a date from a monthly view. +package datepicker + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" +) + +// Focus is a value passed to `model.SetFocus` to indicate what component +// controls should be available. +type Focus int + +const ( + // FocusNone is a value passed to `model.SetFocus` to ignore all date altering key msgs + FocusNone Focus = iota + // FocusHeaderMonth is a value passed to `model.SetFocus` to accept key msgs that change the month + FocusHeaderMonth + // FocusHeaderYear is a value passed to `model.SetFocus` to accept key msgs that change the year + FocusHeaderYear + // FocusCalendar is a value passed to `model.SetFocus` to accept key msgs that change the week or date + FocusCalendar +) + +//go:generate stringer -type=Focus + +// KeyMap is the key bindings for different actions within the datepicker. +type KeyMap struct { + Up key.Binding + Right key.Binding + Down key.Binding + Left key.Binding + FocusPrev key.Binding + FocusNext key.Binding + Quit key.Binding +} + +// DefaultKeyMap returns a KeyMap struct with default values +func DefaultKeyMap() KeyMap { + return KeyMap{ + Up: key.NewBinding(key.WithKeys("up", "k")), + Right: key.NewBinding(key.WithKeys("right", "l")), + Down: key.NewBinding(key.WithKeys("down", "j")), + Left: key.NewBinding(key.WithKeys("left", "h")), + FocusPrev: key.NewBinding(key.WithKeys("shift+tab")), + FocusNext: key.NewBinding(key.WithKeys("tab")), + Quit: key.NewBinding(key.WithKeys("ctrl+c", "q")), + } +} + +// Styles is a struct of lipgloss styles to apply to various elements of the datepicker +type Styles struct { + HeaderPad lipgloss.Style + DatePad lipgloss.Style + + HeaderText lipgloss.Style + Text lipgloss.Style + SelectedText lipgloss.Style + FocusedText lipgloss.Style +} + +// DefaultStyles returns a default `Styles` struct +func DefaultStyles() Styles { + // TODO: refactor for adaptive colors + r := lipgloss.DefaultRenderer() + return Styles{ + HeaderPad: r.NewStyle().Padding(1, 0, 0), + DatePad: r.NewStyle().Padding(0, 1, 1), + HeaderText: r.NewStyle().Bold(true), + Text: r.NewStyle().Foreground(lipgloss.Color("247")), + SelectedText: r.NewStyle().Bold(true), + FocusedText: r.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), + } +} + +// Model is a struct that contains the state of the datepicker component and satisfies +// the `tea.Model` interface +type Model struct { + // Time is the `time.Time` struct that represents the selected date month and year + Time time.Time + + // KeyMap encodes the keybindings recognized by the model + KeyMap KeyMap + + // Styles represent the Styles struct used to render the datepicker + Styles Styles + + // Focused indicates the component which the end user is focused on + Focused Focus + + // Selected indicates whether a date is Selected in the datepicker + Selected bool +} + +// New returns the Model of the datepicker +func New(time time.Time) Model { + return Model{ + Time: time, + KeyMap: DefaultKeyMap(), + Styles: DefaultStyles(), + + Focused: FocusCalendar, + Selected: false, + } +} + +// Init satisfies the `tea.Model` interface. This sends a nil cmd +func (m Model) Init() tea.Cmd { + return nil +} + +// Update changes the state of the datepicker. Update satisfies the `tea.Model` interface +func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.KeyMap.Quit): + return m, tea.Quit + + case key.Matches(msg, m.KeyMap.Up): + m.updateUp() + + case key.Matches(msg, m.KeyMap.Right): + m.updateRight() + + case key.Matches(msg, m.KeyMap.Down): + m.updateDown() + + case key.Matches(msg, m.KeyMap.Left): + m.updateLeft() + + case key.Matches(msg, m.KeyMap.FocusPrev): + switch m.Focused { + case FocusHeaderYear: + m.SetFocus(FocusHeaderMonth) + case FocusCalendar: + m.SetFocus(FocusHeaderYear) + } + + case key.Matches(msg, m.KeyMap.FocusNext): + switch m.Focused { + case FocusHeaderMonth: + m.SetFocus(FocusHeaderYear) + case FocusHeaderYear: + m.SetFocus(FocusCalendar) + } + } + } + return m, nil +} + +func (m *Model) updateUp() { + switch m.Focused { + case FocusHeaderYear: + m.LastYear() + case FocusHeaderMonth: + m.LastMonth() + case FocusCalendar: + m.LastWeek() + case FocusNone: + // do nothing + } +} + +func (m *Model) updateRight() { + switch m.Focused { + case FocusHeaderYear: + // do nothing + case FocusHeaderMonth: + m.SetFocus(FocusHeaderYear) + case FocusCalendar: + m.Tomorrow() + case FocusNone: + // do nothing + } + +} +func (m *Model) updateDown() { + switch m.Focused { + case FocusHeaderYear: + m.NextYear() + case FocusHeaderMonth: + m.NextMonth() + case FocusCalendar: + m.NextWeek() + case FocusNone: + // do nothing + } +} +func (m *Model) updateLeft() { + switch m.Focused { + case FocusHeaderYear: + m.SetFocus(FocusHeaderMonth) + case FocusHeaderMonth: + // do nothing + case FocusCalendar: + m.Yesterday() + case FocusNone: + // do nothing + } +} + +// View renders a month view as a multiline string in the bubbletea application. +// View satisfies the `tea.Model` interface. +func (m Model) View() string { + + var b strings.Builder + month := m.Time.Month() + year := m.Time.Year() + + tMonth, tYear := month.String(), strconv.Itoa(year) + + if m.Focused == FocusHeaderMonth { + tMonth = m.Styles.FocusedText.Render(tMonth) + } else { + tMonth = m.Styles.HeaderText.Render(tMonth) + } + + if m.Focused == FocusHeaderYear { + tYear = m.Styles.FocusedText.Render(tYear) + } else { + tYear = m.Styles.HeaderText.Render(tYear) + } + + title := m.Styles.HeaderPad.Render(fmt.Sprintf("%s %s\n", tMonth, tYear)) + + // get all the dates of the current month + firstDayOfTheMonth := time.Date(year, month, 1, 0, 0, 0, 0, time.UTC) + + lastSundayOfLastMonth := firstDayOfTheMonth.AddDate(0, 0, -1) + for lastSundayOfLastMonth.Weekday() != time.Sunday { + lastSundayOfLastMonth = lastSundayOfLastMonth.AddDate(0, 0, -1) + } + + lastDayOfTheMonth := firstDayOfTheMonth.AddDate(0, 1, -1) + + firstSundayOfNextMonth := lastDayOfTheMonth.AddDate(0, 0, 1) + for firstSundayOfNextMonth.Weekday() != time.Sunday { + firstSundayOfNextMonth = firstSundayOfNextMonth.AddDate(0, 0, 1) + } + + day := lastSundayOfLastMonth + if firstDayOfTheMonth.Weekday() == time.Sunday { + day = firstDayOfTheMonth + } + + weekHeaders := []string{"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"} + for i, h := range weekHeaders { + weekHeaders[i] = m.Styles.DatePad.Inherit(m.Styles.HeaderText).Render(h) + } + + cal := [][]string{weekHeaders} + j := 1 + + for day.Before(firstSundayOfNextMonth) { + if j >= len(cal) { + cal = append(cal, []string{}) + } + out := " " + if day.Month() == month { + out = fmt.Sprintf("%02d", day.Day()) + } + + padding := m.Styles.DatePad + textStyle := m.Styles.Text + if !m.Selected { + // skip modifications to the date + } else if day.Day() == m.Time.Day() && day.Month() == m.Time.Month() && m.Focused == FocusCalendar { + textStyle = m.Styles.FocusedText + } else if day.Day() == m.Time.Day() && day.Month() == m.Time.Month() { + textStyle = m.Styles.SelectedText + } + + out = padding.Inherit(textStyle).Render(out) + cal[j] = append(cal[j], out) + + if day.Weekday() == time.Saturday { + j++ + } + day = day.AddDate(0, 0, 1) + } + + rows := []string{title} + for _, row := range cal { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Center, row...)) + } + b.WriteString(lipgloss.JoinVertical(lipgloss.Center, rows...)) + + return b.String() +} + +// SetsFocus focuses one of the datepicker components. This can also be used to blur +// the datepicker by passing the Focus `FocusNone`. +func (m *Model) SetFocus(f Focus) { + m.Focused = f +} + +// Blur sets the datepicker focus to `FocusNone` +func (m *Model) Blur() { + m.Focused = FocusNone +} + +// SetTime sets the model's `Time` struct and is used as reference to the selected date +func (m *Model) SetTime(t time.Time) { + m.Time = t +} + +// LastWeek sets the model's `Time` struct back 7 days +func (m *Model) LastWeek() { + m.Time = m.Time.AddDate(0, 0, -7) +} + +// NextWeek sets the model's `Time` struct forward 7 days +func (m *Model) NextWeek() { + m.Time = m.Time.AddDate(0, 0, 7) +} + +// Yesterday sets the model's `Time` struct back 1 day +func (m *Model) Yesterday() { + m.Time = m.Time.AddDate(0, 0, -1) +} + +// Tomorrow sets the model's `Time` struct forward 1 day +func (m *Model) Tomorrow() { + m.Time = m.Time.AddDate(0, 0, 1) +} + +// LastMonth sets the model's `Time` struct back 1 month +func (m *Model) LastMonth() { + m.Time = m.Time.AddDate(0, -1, 0) +} + +// NextMonth sets the model's `Time` struct forward 1 month +func (m *Model) NextMonth() { + m.Time = m.Time.AddDate(0, 1, 0) +} + +// LastYear sets the model's `Time` struct back 1 year +func (m *Model) LastYear() { + m.Time = m.Time.AddDate(-1, 0, 0) +} + +// NextYear sets the model's `Time` struct forward 1 year +func (m *Model) NextYear() { + m.Time = m.Time.AddDate(1, 0, 0) +} + +// SelectDate changes the model's Selected to true +func (m *Model) SelectDate() { + m.Selected = true +} + +// UnselectDate changes the model's Selected to false +func (m *Model) UnselectDate() { + m.Selected = false +} diff --git a/cmd/slackdump/internal/ui/cfgui/cfgui.go b/cmd/slackdump/internal/ui/cfgui/cfgui.go index 35c21438..98174218 100644 --- a/cmd/slackdump/internal/ui/cfgui/cfgui.go +++ b/cmd/slackdump/internal/ui/cfgui/cfgui.go @@ -4,6 +4,7 @@ import ( "context" tea "github.com/charmbracelet/bubbletea" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) // Show initialises and runs the configuration UI. @@ -23,5 +24,15 @@ func New() configmodel { return configmodel{ cfg: effectiveConfig(), end: end, + Style: 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/configuration.go b/cmd/slackdump/internal/ui/cfgui/configuration.go index c3290234..1d463374 100644 --- a/cmd/slackdump/internal/ui/cfgui/configuration.go +++ b/cmd/slackdump/internal/ui/cfgui/configuration.go @@ -24,6 +24,7 @@ type parameter struct { Name string Value string Description string + Inline bool Model tea.Model } @@ -62,8 +63,9 @@ func effectiveConfig() configuration { { Name: "Output", Value: cfg.Output, + Inline: true, Description: "Output directory", - Model: updaters.NewFileNew(&cfg.Output, true), + Model: updaters.NewFileNew(&cfg.Output, "ZIP or Directory", false, true), }, }, }, diff --git a/cmd/slackdump/internal/ui/cfgui/model.go b/cmd/slackdump/internal/ui/cfgui/model.go index 9bfca585..adda7927 100644 --- a/cmd/slackdump/internal/ui/cfgui/model.go +++ b/cmd/slackdump/internal/ui/cfgui/model.go @@ -5,7 +5,8 @@ import ( "strings" tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui/updaters" ) @@ -13,30 +14,51 @@ const ( sEmpty = "" sTrue = "[x]" sFalse = "[ ]" - cursor = ">" + cursorChar = ">" alignGroup = "" alignParam = " " 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 cfg configuration cursor int end int + Style Style child tea.Model + state state } func (m configmodel) Init() tea.Cmd { return nil } +type state uint8 + +const ( + selecting state = iota + editing + inline +) + func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmds []tea.Cmd - if _, ok := msg.(updaters.WMClose); m.child != nil && !ok { + if _, ok := msg.(updaters.WMClose); m.child != nil && !ok && m.state != selecting { child, cmd := m.child.Update(msg) m.child = child return m, cmd @@ -45,6 +67,7 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case updaters.WMClose: // child sends a close message + m.state = selecting m.child = nil cmds = append(cmds, refreshCfgCmd) case wmRefresh: @@ -76,13 +99,18 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if i == notFound || j == notFound { return m, nil } - if m.cfg[i].params[j].Model != nil { - m.child = m.cfg[i].params[j].Model + if params := m.cfg[i].params[j]; params.Model != nil { + if params.Inline { + m.state = inline + } else { + m.state = editing + } + m.child = params.Model cmds = append(cmds, m.child.Init()) } case "q", "esc", "ctrl+c": // child is active - if m.child != nil { + if m.state != selecting { break } m.finished = true @@ -93,50 +121,52 @@ func (m configmodel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(cmds...) } -// formatting functions -var ( - fmtgrp = cfg.Theme.Focused.Title.Render - fmtname = cfg.Theme.Focused.SelectedOption.Render - fmtvalactive = cfg.Theme.Focused.UnselectedOption.Render - fmtvalinact = cfg.Theme.Focused.Description.Render - fmtdescr = cfg.Theme.Focused.Description.Render -) - func (m configmodel) View() string { if m.finished { return "" } - if m.child != nil && len(m.child.View()) > 0 { + if m.child != nil && len(m.child.View()) > 0 && m.state == editing { return m.child.View() } + return ui.DefaultTheme().Focused.Border.Render(m.view()) +} +func (m configmodel) view() string { var buf strings.Builder line := 0 descr := "" for i, group := range m.cfg { - buf.WriteString(alignGroup + fmtgrp(group.name)) + buf.WriteString(alignGroup + m.Style.Title.Render(group.name)) buf.WriteString("\n") keyLen, valLen := group.maxLen() for j, param := range group.params { - if line == m.cursor { - buf.WriteString(cursor) + selected := line == m.cursor + if selected { + buf.WriteString(m.Style.Cursor.Render(cursorChar)) descr = m.cfg[i].params[j].Description } else { buf.WriteString(" ") } - valfmt := fmtvalinact + + valfmt := m.Style.ValueDisabled if param.Model != nil { - valfmt = fmtvalactive + valfmt = m.Style.ValueEnabled } - fmt.Fprintf(&buf, alignParam+ - fmtname(fmt.Sprintf("% *s", keyLen, param.Name))+" "+ - valfmt(fmt.Sprintf("%-*s", valLen, nvl(param.Value)))+"\n", - ) + namefmt := m.Style.Name + if selected { + namefmt = m.Style.SelectedName + } + fmt.Fprintf(&buf, alignParam+namefmt.Render(fmt.Sprintf("% *s", keyLen, param.Name))+" ") + if selected && m.state == inline { + buf.WriteString(m.child.View() + "\n") + } else { + fmt.Fprintf(&buf, valfmt.Render(fmt.Sprintf("%-*s", valLen, nvl(param.Value)))+"\n") + } line++ } } - buf.WriteString(alignGroup + fmtdescr(descr)) + buf.WriteString(alignGroup + m.Style.Description.Render(descr)) return buf.String() } diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/commands.go b/cmd/slackdump/internal/ui/cfgui/updaters/commands.go index a9702703..3ca49c49 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/commands.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/commands.go @@ -2,7 +2,7 @@ package updaters import tea "github.com/charmbracelet/bubbletea" -type WMClose = struct{} +type WMClose struct{} // OnClose defines the global command to close the program. It is set // by default to [CmdClose], but if running standalone, one must set it @@ -12,3 +12,12 @@ var OnClose = CmdClose func CmdClose() tea.Msg { return WMClose{} } + +// WMError is sent when an error occurs, for example, a validation error, +// so that caller can display the error message. +type WMError error + +// CmdError sends an error message. +func CmdError(err error) tea.Msg { + return err +} diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/date.go b/cmd/slackdump/internal/ui/cfgui/updaters/date.go index 068ba757..299cc708 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/date.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/date.go @@ -6,9 +6,9 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - datepicker "github.com/ethanefung/bubble-datepicker" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/btime" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/bubbles/datepicker" ) type DateModel struct { @@ -24,14 +24,22 @@ type DateModel struct { func NewDTTM(ptrTime *time.Time) DateModel { m := datepicker.New(*ptrTime) + m.Styles = datepicker.Styles{ + HeaderPad: lipgloss.NewStyle().Padding(1, 0, 0), + DatePad: lipgloss.NewStyle().Padding(0, 1, 1), + HeaderText: lipgloss.NewStyle().Bold(true), + Text: lipgloss.NewStyle().Foreground(lipgloss.Color("247")), + SelectedText: lipgloss.NewStyle().Bold(true), + FocusedText: lipgloss.NewStyle().Foreground(lipgloss.Color("212")).Bold(true), + } t := btime.New(m.Time) m.SelectDate() return DateModel{ Value: ptrTime, dm: m, tm: t, - focusstyle: cfg.WizStyle.FocusedBorder, - blurstyle: cfg.WizStyle.BlurredBorder, + focusstyle: ui.DefaultTheme().Focused.Border, + blurstyle: ui.DefaultTheme().Blurred.Border, timeEnabled: true, } } @@ -63,7 +71,7 @@ func (m DateModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.tm.SetTime(msg.v) case tea.KeyMsg: switch msg.String() { - case "esc", "ctrl+c": + case "esc", "ctrl+c", "q": m.finishing = true return m, OnClose case "enter": @@ -119,7 +127,7 @@ func (m DateModel) View() string { var b strings.Builder - help := cfg.Theme.Help.Ellipsis.Render("arrow keys: adjust • tab/shift+tab: switch fields\nenter: select • backspace: clear • esc: cancel") + help := ui.DefaultTheme().Help.Ellipsis.Render("arrow keys: adjust • tab/shift+tab: switch fields\nenter: select • backspace: clear • esc: cancel") var dateStyle lipgloss.Style var timeStyle lipgloss.Style @@ -137,7 +145,6 @@ func (m DateModel) View() string { lipgloss.Center, dateStyle.Render(m.dm.View()), timeStyle.Render(m.tm.View()), - help, )) } else { b.WriteString(lipgloss.JoinVertical( diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go b/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go index b28a28c2..ce89760c 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/examples/filenew/main.go @@ -11,7 +11,7 @@ import ( func main() { var s string = "main.go" updaters.OnClose = tea.Quit - m := updaters.NewFileNew(&s, true) + m := updaters.NewFileNew(&s, "enter filename", true, true) mod, err := tea.NewProgram(m).Run() if err != nil { panic(err) diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go b/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go index 1590597f..e3eea431 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/examples/string/main.go @@ -12,7 +12,7 @@ import ( func main() { var s string = "previous value" updaters.OnClose = tea.Quit - m := updaters.NewString(&s, ui.ValidateNotExists) + m := updaters.NewString(&s, "Enter ZIP file or directory name", true, ui.ValidateNotExists) mod, err := tea.NewProgram(m).Run() if err != nil { panic(err) diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go b/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go index 1f614871..0bf64d7e 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/filepick.go @@ -6,32 +6,32 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/rusq/rbubbles/filemgr" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) type FileModel struct { - fp filemgr.Model - v *string - validate func(s string) error - err error - errStyle lipgloss.Style + fp filemgr.Model + v *string + validate func(s string) error + err error + borderStyle lipgloss.Style + errStyle lipgloss.Style } func NewExistingFile(ptrStr *string, f filemgr.Model, validateFn func(s string) error) FileModel { f.Focus() f.ShowHelp = true f.Style = filemgr.Style{ - Normal: cfg.Theme.Focused.File, - Directory: cfg.Theme.Focused.Directory, - Inverted: lipgloss.NewStyle(). - Foreground(cfg.Theme.Focused.FocusedButton.GetForeground()). - Background(cfg.Theme.Focused.FocusedButton.GetBackground()), + Normal: ui.DefaultTheme().Focused.UnselectedFile, + Directory: ui.DefaultTheme().Focused.Directory, + Inverted: ui.DefaultTheme().Focused.SelectedFile, } return FileModel{ - fp: f, - v: ptrStr, - validate: validateFn, - errStyle: cfg.Theme.Focused.ErrorMessage, + fp: f, + v: ptrStr, + validate: validateFn, + borderStyle: ui.DefaultTheme().Focused.Border, + errStyle: ui.DefaultTheme().Error, } } @@ -74,5 +74,5 @@ func (m FileModel) View() string { if m.err != nil { buf.WriteString(m.errStyle.Render(m.err.Error())) } - return buf.String() + return m.borderStyle.Render(buf.String()) } diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go b/cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go index 23580918..2fba3f1f 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/filepick_new.go @@ -16,9 +16,9 @@ type FileNewModel struct { finishing bool } -func NewFileNew(v *string, overwrite bool) FileNewModel { +func NewFileNew(v *string, placeholder string, showPrompt bool, overwrite bool) FileNewModel { m := FileNewModel{ - entry: NewString(v, ui.ValidateNotExists), + entry: NewString(v, placeholder, showPrompt, ui.ValidateNotExists), cnfrm: newConfirmForm(), allowOvwr: overwrite, } @@ -107,7 +107,6 @@ func (m FileNewModel) View() string { return "" } var buf strings.Builder - buf.WriteString("Enter the name of the new filename:\n\n") buf.WriteString(m.entry.View()) if m.confirming { buf.WriteString("\n\n" + m.cnfrm.View()) diff --git a/cmd/slackdump/internal/ui/cfgui/updaters/string.go b/cmd/slackdump/internal/ui/cfgui/updaters/string.go index b21ad4ea..bc95e78f 100644 --- a/cmd/slackdump/internal/ui/cfgui/updaters/string.go +++ b/cmd/slackdump/internal/ui/cfgui/updaters/string.go @@ -6,27 +6,37 @@ import ( "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) type StringModel struct { - Value *string - m textinput.Model - errStyle lipgloss.Style + Value *string + m textinput.Model + errStyle lipgloss.Style + borderStyle lipgloss.Style + finishing bool } // NewString creates a new stringUpdateModel -func NewString(ptrStr *string, validateFn func(s string) error) StringModel { +func NewString(ptrStr *string, placeholder string, showPrompt bool, validateFn func(s string) error) StringModel { m := textinput.New() m.Focus() m.Validate = validateFn m.EchoMode = textinput.EchoNormal - m.CharLimit = 80 + m.CharLimit = 255 m.SetValue(*ptrStr) - + m.Cursor.Style = ui.DefaultTheme().Focused.Cursor + m.PromptStyle = ui.DefaultTheme().Focused.Title + m.TextStyle = ui.DefaultTheme().Focused.Text + m.Placeholder = placeholder + m.Width = 40 + if !showPrompt { + m.Prompt = "" + } return StringModel{ Value: ptrStr, m: m, - errStyle: lipgloss.NewStyle().Foreground(lipgloss.Color("9")), + errStyle: ui.DefaultTheme().Error, } } @@ -42,8 +52,10 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "esc", "ctrl+c": + m.finishing = true return m, OnClose case "enter": + m.finishing = true *m.Value = m.m.Value() return m, OnClose } @@ -56,11 +68,16 @@ func (m StringModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m StringModel) View() string { + if m.finishing { + return "" + } var buf strings.Builder - buf.WriteString(m.m.View()) + strs := make([]string, 0, 2) + strs = append(strs, m.m.View()) if m.m.Err != nil { - buf.WriteString("\n" + m.errStyle.Render(m.m.Err.Error())) + strs = append(strs, "\n"+m.errStyle.Render(m.m.Err.Error())) } + buf.WriteString(lipgloss.JoinVertical(lipgloss.Top, strs...)) return buf.String() } diff --git a/cmd/slackdump/internal/ui/dumpui/dumpui.go b/cmd/slackdump/internal/ui/dumpui/dumpui.go new file mode 100644 index 00000000..2a79a24c --- /dev/null +++ b/cmd/slackdump/internal/ui/dumpui/dumpui.go @@ -0,0 +1,60 @@ +// Package dumpui provides a universal wizard for running dump-family commands. +package dumpui + +import ( + "context" + + "github.com/charmbracelet/huh" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui/cfgui" +) + +type Wizard struct { + Title string + Particulars string + Cmd *base.Command +} + +func (w *Wizard) Run(ctx context.Context) error { + var ( + action string = "run" + ) + + menu := func() *huh.Form { + return huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(w.Title). + Options( + huh.NewOption("Run "+w.Particulars, "run"), + huh.NewOption("Configuration", "config"), + huh.NewOption(ui.MenuSeparator, ""), + huh.NewOption("Exit to Main Menu", "exit"), + ).Value(&action), + ), + ).WithTheme(ui.HuhTheme).WithAccessible(cfg.AccessibleMode) + } + +LOOP: + for { + if err := menu().RunWithContext(ctx); err != nil { + return err + } + switch action { + case "exit": + break LOOP + case "config": + if err := cfgui.Show(ctx); err != nil { + return err + } + case "run": + if err := w.Cmd.Run(ctx, w.Cmd, nil); err != nil { + return err + } + } + } + + return nil +} diff --git a/cmd/slackdump/internal/ui/filepicker.go b/cmd/slackdump/internal/ui/filepicker.go index bcdde4e6..3a94df76 100644 --- a/cmd/slackdump/internal/ui/filepicker.go +++ b/cmd/slackdump/internal/ui/filepicker.go @@ -7,7 +7,6 @@ import ( "github.com/charmbracelet/bubbles/filepicker" tea "github.com/charmbracelet/bubbletea" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" ) type FileSystemModel struct { @@ -22,7 +21,7 @@ func NewFilePicker(prompt string, homedir string, allowedExt ...string) FileSyst fp := filepicker.New() fp.AllowedTypes = allowedExt fp.CurrentDirectory = homedir - fp.Styles.Cursor = cfg.Theme.Focused.SelectedOption + fp.Styles.Cursor = HuhTheme.Focused.SelectedOption return FileSystemModel{ filepicker: fp, diff --git a/cmd/slackdump/internal/ui/theme.go b/cmd/slackdump/internal/ui/theme.go new file mode 100644 index 00000000..ff2f48aa --- /dev/null +++ b/cmd/slackdump/internal/ui/theme.go @@ -0,0 +1,162 @@ +package ui + +import ( + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/huh" + "github.com/charmbracelet/lipgloss" +) + +var HuhTheme = ThemeBase16Ext() // Theme is the default Wizard theme. + +type Theme struct { + Focused ControlStyle + Blurred ControlStyle + + StatusText lipgloss.Style + Error lipgloss.Style + Help help.Styles +} + +type ControlStyle struct { + // Border defines the border style for the control. It should not have + // any color, otherwise it will paint everything that does not have + // the color set. + Border lipgloss.Style + Title lipgloss.Style + Description lipgloss.Style + + Text lipgloss.Style + + Cursor lipgloss.Style + SelectedLine lipgloss.Style + + Selected lipgloss.Style + Unselected lipgloss.Style + + SelectedFile lipgloss.Style + UnselectedFile lipgloss.Style + DisabledFile lipgloss.Style + Directory lipgloss.Style + + Options OptionStyle +} + +type OptionStyle struct { + Section lipgloss.Style + Name lipgloss.Style + EnabledValue lipgloss.Style + DisabledValue lipgloss.Style + SelectedName lipgloss.Style +} + +var ( + black = lipgloss.Color("0") + red = lipgloss.Color("1") + green = lipgloss.Color("2") + blue = lipgloss.Color("4") + yellow = lipgloss.Color("3") + cyan = lipgloss.AdaptiveColor{Light: "4", Dark: "6"} + purple = lipgloss.Color("5") + white = lipgloss.AdaptiveColor{Light: "0", Dark: "7"} + gray = lipgloss.Color("8") + ltred = lipgloss.Color("9") +) + +func DefaultTheme() Theme { + // https://gist.github.com/JBlond/2fea43a3049b38287e5e9cefc87b2124 + return Theme{ + Focused: ControlStyle{ + Border: lipgloss.NewStyle().BorderLeft(true).BorderForeground(cyan).BorderStyle(lipgloss.ThickBorder()).Padding(0, 1), + Title: lipgloss.NewStyle().Foreground(green).Bold(true), + Description: lipgloss.NewStyle().Foreground(white), + Cursor: lipgloss.NewStyle().Foreground(yellow), + SelectedLine: lipgloss.NewStyle().Background(green).Foreground(black), + Selected: lipgloss.NewStyle().Foreground(green), + Unselected: lipgloss.NewStyle().Foreground(white), + SelectedFile: lipgloss.NewStyle().Foreground(green), + UnselectedFile: lipgloss.NewStyle().Foreground(white), + DisabledFile: lipgloss.NewStyle().Foreground(gray), + Directory: lipgloss.NewStyle().Foreground(blue), + Text: lipgloss.NewStyle().Foreground(white), + Options: OptionStyle{ + Section: lipgloss.NewStyle().Foreground(cyan).Bold(true), + Name: lipgloss.NewStyle().Foreground(green), + EnabledValue: lipgloss.NewStyle().Foreground(white), + DisabledValue: lipgloss.NewStyle().Foreground(green), + SelectedName: lipgloss.NewStyle().Foreground(black).Background(green), + }, + }, + Blurred: ControlStyle{ + Border: lipgloss.NewStyle().BorderLeft(true).BorderStyle(lipgloss.NormalBorder()).Padding(0, 1), + Title: lipgloss.NewStyle().Foreground(gray).Bold(true), + Description: lipgloss.NewStyle().Foreground(gray), + Cursor: lipgloss.NewStyle().Foreground(gray), + SelectedLine: lipgloss.NewStyle().Background(gray).Foreground(black), + Selected: lipgloss.NewStyle().Foreground(gray), + Unselected: lipgloss.NewStyle().Foreground(gray), + SelectedFile: lipgloss.NewStyle().Foreground(gray), + UnselectedFile: lipgloss.NewStyle().Foreground(gray), + DisabledFile: lipgloss.NewStyle().Foreground(gray), + Directory: lipgloss.NewStyle().Foreground(gray), + Text: lipgloss.NewStyle().Foreground(gray), + Options: OptionStyle{ + Section: lipgloss.NewStyle().Foreground(gray).Bold(true), + Name: lipgloss.NewStyle().Foreground(gray), + EnabledValue: lipgloss.NewStyle().Foreground(gray), + DisabledValue: lipgloss.NewStyle().Foreground(gray), + SelectedName: lipgloss.NewStyle().Foreground(gray).Underline(true).UnderlineSpaces(false), + }, + }, + StatusText: lipgloss.NewStyle().Foreground(green), + Error: lipgloss.NewStyle().Foreground(red).Bold(true), + Help: help.Styles{ + ShortDesc: lipgloss.NewStyle().Foreground(white), + FullDesc: lipgloss.NewStyle().Foreground(white), + Ellipsis: lipgloss.NewStyle().Foreground(white), + ShortKey: lipgloss.NewStyle().Foreground(green), + FullKey: lipgloss.NewStyle().Foreground(green), + ShortSeparator: lipgloss.NewStyle().Foreground(white), + FullSeparator: lipgloss.NewStyle().Foreground(white), + }, + } +} + +// ThemeBase16Ext returns a modified Base16 theme based on huh.ThemeBase16. +func ThemeBase16Ext() *huh.Theme { + t := huh.ThemeBase() + + t.Focused.Base = t.Focused.Base.BorderForeground(gray) + t.Focused.Title = t.Focused.Title.Foreground(cyan) + t.Focused.NoteTitle = t.Focused.NoteTitle.Foreground(cyan) + t.Focused.Directory = t.Focused.Directory.Foreground(cyan) + t.Focused.Description = t.Focused.Description.Foreground(gray) + t.Focused.ErrorIndicator = t.Focused.ErrorIndicator.Foreground(ltred) + t.Focused.ErrorMessage = t.Focused.ErrorMessage.Foreground(ltred) + t.Focused.SelectSelector = t.Focused.SelectSelector.Foreground(yellow) + t.Focused.NextIndicator = t.Focused.NextIndicator.Foreground(yellow) + t.Focused.PrevIndicator = t.Focused.PrevIndicator.Foreground(yellow) + t.Focused.Option = t.Focused.Option.Foreground(white) + t.Focused.MultiSelectSelector = t.Focused.MultiSelectSelector.Foreground(yellow) + t.Focused.SelectedOption = t.Focused.SelectedOption.Foreground(black).Background(green) + t.Focused.SelectedPrefix = t.Focused.SelectedPrefix.Foreground(green) + t.Focused.UnselectedOption = t.Focused.UnselectedOption.Foreground(white) + t.Focused.FocusedButton = t.Focused.FocusedButton.Foreground(white).Background(purple) + t.Focused.BlurredButton = t.Focused.BlurredButton.Foreground(white).Background(black) + + t.Focused.TextInput.Cursor.Foreground(purple) + t.Focused.TextInput.Placeholder.Foreground(gray) + t.Focused.TextInput.Prompt.Foreground(yellow) + + t.Blurred = t.Focused + t.Blurred.Base = t.Blurred.Base.BorderStyle(lipgloss.HiddenBorder()) + t.Blurred.NoteTitle = t.Blurred.NoteTitle.Foreground(gray) + t.Blurred.Title = t.Blurred.NoteTitle.Foreground(gray) + + t.Blurred.TextInput.Prompt = t.Blurred.TextInput.Prompt.Foreground(gray) + t.Blurred.TextInput.Text = t.Blurred.TextInput.Text.Foreground(white) + + t.Blurred.NextIndicator = lipgloss.NewStyle() + t.Blurred.PrevIndicator = lipgloss.NewStyle() + + return t +} diff --git a/cmd/slackdump/internal/ui/ui.go b/cmd/slackdump/internal/ui/ui.go index 8b1382a5..9c7b417c 100644 --- a/cmd/slackdump/internal/ui/ui.go +++ b/cmd/slackdump/internal/ui/ui.go @@ -1,6 +1,11 @@ // Package ui contains some common UI elements. package ui +const ( + // MenuSeparator is the separator to use in the wizard menus. + MenuSeparator = "────────────" +) + type inputOptions struct { fileSelectorOpt help string diff --git a/cmd/slackdump/internal/wizard/config.go b/cmd/slackdump/internal/wizard/config.go index d98fc1a9..0e719c34 100644 --- a/cmd/slackdump/internal/wizard/config.go +++ b/cmd/slackdump/internal/wizard/config.go @@ -1,14 +1,7 @@ package wizard import ( - "context" "errors" - "os" - "time" - - "github.com/charmbracelet/huh" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/apiconfig" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" ) // initFlags initializes flags based on the key-value pairs. @@ -35,134 +28,3 @@ func initFlags(keyval ...any) ([]string, error) { } return flags, nil } - -func Config(ctx context.Context) error { - const timeExample = "2021-12-31T23:59:59" - var ( - switches string - dateFrom string = cfg.Oldest.String() - dateTo string = cfg.Latest.String() - output string = cfg.StripZipExt(cfg.Output) - ) - - flags, err := initFlags(cfg.ForceEnterprise, "enterprise", cfg.DownloadFiles, "files") - if err != nil { - return err - } - - form := huh.NewForm( - huh.NewGroup( - huh.NewInput().Title("Output directory"). - Description("Must not exist, will be created"). - Validate(validateNotExist). - Value(&output), - huh.NewInput(). - Title("Start date"). - DescriptionFunc(func() string { - switch dateFrom { - case "": - return "From the beginning of times" - default: - return "From the specified date, i.e. " + timeExample - } - }, &dateFrom). - Validate(validateDateLayout). - Value(&dateFrom), - huh.NewInput(). - Title("End date"). - DescriptionFunc(func() string { - switch dateTo { - case "": - return "Until now" - default: - return "Until the specified date, i.e. " + timeExample - } - }, &dateTo). - Validate(validateDateLayout). - Value(&dateTo), - huh.NewFilePicker(). - Title("API limits configuration file"). - Description("No file means default limits"). - AllowedTypes([]string{".yaml", ".yml"}). - Validate(validateAPIconfig). - Value(&cfg.ConfigFile), - huh.NewMultiSelect[string](). - Title("Switches"). - Options( - huh.NewOption("Enterprise mode", "enterprise"), - huh.NewOption("Include files and attachments", "files"), - ).Value(&flags). - DescriptionFunc(func() string { - switch switches { - case "enterprise": - return "Enterprise mode is required if you're running Slack Enterprise Grid" - case "files": - return "Files will be downloaded along the messages" - } - return "" - }, &switches), - ), - ).WithTheme(cfg.Theme).WithAccessible(cfg.AccessibleMode) - - if err := form.RunWithContext(ctx); err != nil { - return err - } - // TODO: parse dates - if err := cfg.Oldest.Set(dateFrom); err != nil { - return err - } - if dateTo == "" { - cfg.Latest = cfg.TimeValue(time.Now()) - } else { - if err := cfg.Latest.Set(dateTo); err != nil { - return err - } - } - if time.Time(cfg.Latest).Before(time.Time(cfg.Oldest)) { - cfg.Latest, cfg.Oldest = cfg.Oldest, cfg.Latest - } - - cfg.DownloadFiles, cfg.ForceEnterprise = false, false - for _, f := range flags { - switch f { - case "enterprise": - cfg.ForceEnterprise = true - case "files": - cfg.DownloadFiles = true - } - } - cfg.Output = cfg.StripZipExt(output) - - return nil -} - -func validateDateLayout(s string) error { - if s == "" { - return nil - } - var t cfg.TimeValue - return t.Set(s) -} - -func validateAPIconfig(s string) error { - if s == "" { - return nil - } - if _, err := os.Stat(s); err != nil { - return err - } - if err := apiconfig.CheckFile(s); err != nil { - return errors.New("not a valid API limits configuration file") - } - return nil -} - -func validateNotExist(s string) error { - if s == "" { - return errors.New("output directory is required") - } - if _, err := os.Stat(s); err == nil { - return errors.New("output directory already exists") - } - return nil -} diff --git a/cmd/slackdump/internal/wizard/model.go b/cmd/slackdump/internal/wizard/model.go index fba3a144..834e5a0a 100644 --- a/cmd/slackdump/internal/wizard/model.go +++ b/cmd/slackdump/internal/wizard/model.go @@ -6,7 +6,7 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/huh" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/bootstrap" - "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" ) type model struct { @@ -35,7 +35,7 @@ func newModel(m *menu) model { Description("Slack workspace: " + bootstrap.CurrentWsp()). Options(options...), ), - ).WithTheme(cfg.Theme), + ).WithTheme(ui.HuhTheme), } } diff --git a/cmd/slackdump/internal/workspace/wizard.go b/cmd/slackdump/internal/workspace/wizard.go index 677fd39c..a6bac54f 100644 --- a/cmd/slackdump/internal/workspace/wizard.go +++ b/cmd/slackdump/internal/workspace/wizard.go @@ -9,6 +9,7 @@ import ( "github.com/charmbracelet/lipgloss" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/cfg" "github.com/rusq/slackdump/v3/cmd/slackdump/internal/golang/base" + "github.com/rusq/slackdump/v3/cmd/slackdump/internal/ui" "github.com/rusq/slackdump/v3/internal/cache" "github.com/rusq/slackdump/v3/logger" ) @@ -56,13 +57,13 @@ func WorkspaceSelectModel(ctx context.Context, m *cache.Manager) (tea.Model, err s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). - Foreground(cfg.Theme.Focused.NoteTitle.GetForeground()). + Foreground(ui.HuhTheme.Focused.NoteTitle.GetForeground()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(false) s.Selected = s.Selected. - Foreground(cfg.Theme.Focused.Option.GetBackground()). - Background(cfg.Theme.Focused.SelectedOption.GetForeground()). + Foreground(ui.HuhTheme.Focused.Option.GetBackground()). + Background(ui.HuhTheme.Focused.SelectedOption.GetForeground()). Bold(false) t.SetStyles(s) @@ -99,7 +100,7 @@ func wizSelect(ctx context.Context, cmd *base.Command, args []string) error { return nil } -var baseStyle = cfg.Theme.Form +var baseStyle = ui.HuhTheme.Form type selectModel struct { table table.Model @@ -131,5 +132,5 @@ func (m selectModel) View() string { if m.finished { return "" // don't render the table if we've selected a workspace } - return baseStyle.Render(m.table.View()) + "\n\n" + cfg.Theme.Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.") + return baseStyle.Render(m.table.View()) + "\n\n" + ui.HuhTheme.Help.Ellipsis.Render("Select the workspace with arrow keys, press [Enter] to confirm, [Esc] to cancel.") }