Add help modal and refine modal close behavior

This commit is contained in:
vrubelroman 2026-04-23 14:22:16 +03:00
parent 874a5ed678
commit 544511f00e
2 changed files with 175 additions and 6 deletions

View file

@ -3,6 +3,7 @@ package ui
import "github.com/charmbracelet/bubbles/key" import "github.com/charmbracelet/bubbles/key"
type KeyMap struct { type KeyMap struct {
Help key.Binding
View key.Binding View key.Binding
Edit key.Binding Edit key.Binding
Info key.Binding Info key.Binding
@ -31,6 +32,7 @@ type KeyMap struct {
func DefaultKeyMap() KeyMap { func DefaultKeyMap() KeyMap {
return KeyMap{ return KeyMap{
Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")),
View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")),
Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")),
Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")), Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")),
@ -59,12 +61,12 @@ func DefaultKeyMap() KeyMap {
} }
func (k KeyMap) ShortHelp() []key.Binding { func (k KeyMap) ShortHelp() []key.Binding {
return []key.Binding{k.Info, k.SelectText, k.Copy, k.Move, k.Delete, k.Quit} return []key.Binding{k.Help, k.Copy, k.Move, k.Delete, k.Quit}
} }
func (k KeyMap) FullHelp() [][]key.Binding { func (k KeyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{ return [][]key.Binding{
{k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info}, {k.Help, k.Up, k.Down, k.Open, k.Back, k.Switch, k.Info},
{k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete}, {k.View, k.Edit, k.Copy, k.Move, k.Mkdir, k.Delete},
{k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, {k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit},
} }

View file

@ -28,6 +28,7 @@ const (
modalConfirm modalConfirm
modalCopyProgress modalCopyProgress
modalNotice modalNotice
modalHelp
) )
type fileOpKind int type fileOpKind int
@ -353,6 +354,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch { switch {
case key.Matches(msg, m.keys.Quit): case key.Matches(msg, m.keys.Quit):
return m, tea.Quit return m, tea.Quit
case key.Matches(msg, m.keys.Help):
m.openHelpModal()
return m, nil
case key.Matches(msg, m.keys.View): case key.Matches(msg, m.keys.View):
return m.handleView() return m.handleView()
case key.Matches(msg, m.keys.Edit): case key.Matches(msg, m.keys.Edit):
@ -473,7 +477,11 @@ func (m Model) View() string {
Foreground(m.palette.Text). Foreground(m.palette.Text).
Render(lipgloss.JoinVertical(lipgloss.Left, parts...)) Render(lipgloss.JoinVertical(lipgloss.Left, parts...))
if m.modal.kind != modalNone { if m.modal.kind != modalNone {
view = overlayCenter(view, renderModal(m, m.palette, min(72, m.width-8)), m.width) modalWidth := min(72, m.width-8)
if m.modal.kind == modalHelp {
modalWidth = min(96, m.width-8)
}
view = overlayCenter(view, renderModal(m, m.palette, modalWidth), m.width)
} }
return view return view
} }
@ -482,7 +490,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.modal.kind { switch m.modal.kind {
case modalMkdir: case modalMkdir:
switch { switch {
case key.Matches(msg, m.keys.Cancel): case isModalCloseKey(msg, m.keys):
m.modal = modalState{} m.modal = modalState{}
m.status = "Cancelled" m.status = "Cancelled"
return m, nil return m, nil
@ -502,7 +510,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
case modalConfirm: case modalConfirm:
switch { switch {
case key.Matches(msg, m.keys.Cancel): case isModalCloseKey(msg, m.keys):
m.modal = modalState{} m.modal = modalState{}
m.status = "Cancelled" m.status = "Cancelled"
return m, nil return m, nil
@ -527,6 +535,16 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
case modalCopyProgress: case modalCopyProgress:
if isModalCloseKey(msg, m.keys) {
if m.copyJob == nil {
m.modal = modalState{}
return m, nil
}
m.copyJob.background = true
m.modal = modalState{}
m.status = "Transfer continues in background"
return m, nil
}
if key.Matches(msg, m.keys.Background) { if key.Matches(msg, m.keys.Background) {
if m.copyJob == nil { if m.copyJob == nil {
m.modal = modalState{} m.modal = modalState{}
@ -540,15 +558,26 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
case modalNotice: case modalNotice:
if key.Matches(msg, m.keys.Confirm) || key.Matches(msg, m.keys.Cancel) { if key.Matches(msg, m.keys.Confirm) || isModalCloseKey(msg, m.keys) {
m.modal = modalState{} m.modal = modalState{}
} }
return m, nil return m, nil
case modalHelp:
if isModalCloseKey(msg, m.keys) || key.Matches(msg, m.keys.Confirm) || key.Matches(msg, m.keys.Help) {
m.modal = modalState{}
m.status = "Help closed"
}
return m, nil
} }
return m, nil return m, nil
} }
func isModalCloseKey(msg tea.KeyMsg, keys KeyMap) bool {
return key.Matches(msg, keys.Cancel) || msg.String() == "q"
}
func (m *Model) reloadPane(id PaneID, preserve string) error { func (m *Model) reloadPane(id PaneID, preserve string) error {
pane := m.paneByID(id) pane := m.paneByID(id)
entries, err := vfs.ListDir(pane.Path, vfs.ListOptions{ entries, err := vfs.ListDir(pane.Path, vfs.ListOptions{
@ -996,6 +1025,49 @@ func (m *Model) openConfirmModal(title, body, note string, pending pendingOperat
} }
} }
func (m *Model) openHelpModal() {
sections := []string{
"Navigation",
" j / Down move down",
" k / Up move up",
" PgDn / f page down",
" PgUp / b page up",
" Enter / Right open selected entry",
" Backspace/Left go to parent directory",
" Tab / h / l switch active pane",
" r refresh both panes",
"",
"View and Panels",
" i toggle preview/info pane",
" Ctrl+t toggle text selection mode in text preview",
" Space calculate selected directory size",
" s cycle sort mode",
" . toggle hidden files",
" t cycle theme",
"",
"Dialogs and Transfers",
" Enter / y confirm action",
" Esc / n cancel action",
" b run copy/move in background (progress dialog)",
"",
"Mouse",
" Left click select entry and activate pane",
" Double click open selected entry",
" Right click toggle preview/info mode for clicked entry",
" Wheel scroll list or preview area",
"",
"F-key actions are shown in the footer.",
}
m.modal = modalState{
kind: modalHelp,
title: "Keyboard and Mouse Help",
body: strings.Join(sections, "\n"),
note: "F1/? or Esc to close",
}
m.status = "Help opened"
}
func (m *Model) applyDirSize(path string, size int64) { func (m *Model) applyDirSize(path string, size int64) {
for _, pane := range []*BrowserPane{&m.left, &m.right} { for _, pane := range []*BrowserPane{&m.left, &m.right} {
for idx := range pane.Entries { for idx := range pane.Entries {
@ -1293,6 +1365,9 @@ func renderModal(m Model, palette theme.Palette, width int) string {
if m.modal.kind == modalCopyProgress && m.copyJob != nil { if m.modal.kind == modalCopyProgress && m.copyJob != nil {
return renderCopyProgressModal(*m.copyJob, palette, width) return renderCopyProgressModal(*m.copyJob, palette, width)
} }
if m.modal.kind == modalHelp {
return renderHelpModal(m.modal, palette, width)
}
modal := m.modal modal := m.modal
contentWidth := max(width-4, 1) contentWidth := max(width-4, 1)
@ -1321,6 +1396,98 @@ func renderModal(m Model, palette theme.Palette, width int) string {
return box.Render(strings.Join(lines, "\n")) return box.Render(strings.Join(lines, "\n"))
} }
func renderHelpModal(modal modalState, palette theme.Palette, width int) string {
contentWidth := max(width-4, 1)
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)
lineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Text)
mutedLineStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.Muted)
noteStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true)
spacer := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Render(" ")
sectionColors := []lipgloss.Color{palette.Accent, palette.Warning, palette.FooterKey, palette.Danger}
keyColStyle := lipgloss.NewStyle().Width(24).Background(palette.Panel).Foreground(palette.FooterKey).Bold(true)
descColStyle := lipgloss.NewStyle().Background(palette.Panel).Foreground(palette.Text)
box := lipgloss.NewStyle().
Width(width).
Padding(1, 2).
Background(palette.Panel).
Foreground(palette.Text).
BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(palette.BorderActive)
lines := []string{titleStyle.Render(modal.title), spacer}
sectionIdx := 0
for _, raw := range strings.Split(modal.body, "\n") {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
lines = append(lines, spacer)
continue
}
if strings.HasPrefix(raw, " ") {
keyLabel, action := splitHelpItem(raw)
if action == "" {
lines = append(lines, lineStyle.Render(trimmed))
continue
}
row := keyColStyle.Render(keyLabel) + descColStyle.Render(action)
lines = append(lines, lineStyle.Render(row))
continue
}
if strings.HasSuffix(trimmed, ".") {
lines = append(lines, mutedLineStyle.Render(trimmed))
continue
}
sectionColor := sectionColorForHeader(trimmed, sectionIdx, sectionColors, palette)
sectionIdx++
header := lipgloss.NewStyle().
Width(contentWidth).
Background(palette.Panel).
Foreground(sectionColor).
Bold(true).
Render(trimmed)
lines = append(lines, header)
}
if modal.note != "" {
lines = append(lines, spacer, noteStyle.Render(modal.note))
}
return box.Render(strings.Join(lines, "\n"))
}
func splitHelpItem(raw string) (string, string) {
value := strings.TrimSpace(raw)
for idx := 0; idx < len(value)-1; idx++ {
if value[idx] == ' ' && value[idx+1] == ' ' {
keyLabel := strings.TrimSpace(value[:idx])
action := strings.TrimSpace(value[idx:])
if keyLabel != "" && action != "" {
return keyLabel, action
}
}
}
return value, ""
}
func sectionColorForHeader(header string, idx int, fallback []lipgloss.Color, palette theme.Palette) lipgloss.Color {
switch header {
case "Navigation":
return palette.Folder
case "View and Panels":
return fallback[1]
case "Dialogs and Transfers":
return palette.BinaryFile
case "Mouse":
return fallback[3]
default:
return fallback[idx%len(fallback)]
}
}
func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) string { func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) string {
contentWidth := max(width-4, 1) contentWidth := max(width-4, 1)
titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent) titleStyle := lipgloss.NewStyle().Width(contentWidth).Background(palette.Panel).Bold(true).Foreground(palette.Accent)