diff --git a/internal/ui/app.go b/internal/ui/app.go index 879ad62..7dc50fc 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -36,6 +36,7 @@ const ( ModeProjects ModeStatus ModeBoard + ModeConfirmDelete ) // Model is the root Bubble Tea model. @@ -60,8 +61,9 @@ type Model struct { keys KeyMap styles Styles version string - allTickets []ticket.Ticket - creatingProject bool + allTickets []ticket.Ticket + creatingProject bool + pendingDeleteTitle string } // New creates the root UI model. @@ -124,6 +126,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateStatus(msg) case ModeBoard: return m.updateBoard(msg) + case ModeConfirmDelete: + return m.updateConfirmDelete(msg) default: return m.updateList(msg) } @@ -212,7 +216,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.mode = ModeBoard return m, nil - case key.Matches(msg, m.keys.Delete): + case key.Matches(msg, m.keys.DeleteForce): m.clearStatus() if err := m.deleteSelected(); err != nil { m.setError(err) @@ -221,6 +225,17 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } return m, nil + case key.Matches(msg, m.keys.Delete): + m.clearStatus() + t, ok := m.tickets.Selected() + if !ok { + m.setError(fmt.Errorf("no ticket selected")) + return m, nil + } + m.pendingDeleteTitle = strings.TrimSpace(t.Title) + m.mode = ModeConfirmDelete + return m, nil + case key.Matches(msg, m.keys.Undo): m.clearStatus() if err := m.undo(); err != nil { @@ -324,6 +339,27 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } +func (m Model) updateConfirmDelete(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch { + case key.Matches(msg, m.keys.Escape): + m.pendingDeleteTitle = "" + m.mode = ModeList + m.clearStatus() + return m, nil + + case key.Matches(msg, m.keys.Enter), key.Matches(msg, m.keys.ConfirmYes): + m.pendingDeleteTitle = "" + m.mode = ModeList + if err := m.deleteSelected(); err != nil { + m.setError(err) + } else { + m.setStatus("deleted") + } + return m, nil + } + return m, nil +} + func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch { case key.Matches(msg, m.keys.Escape): diff --git a/internal/ui/helpbar.go b/internal/ui/helpbar.go index acf3424..631ff32 100644 --- a/internal/ui/helpbar.go +++ b/internal/ui/helpbar.go @@ -42,6 +42,11 @@ func (m Model) helpEntries() []helpEntry { {"enter", "apply"}, {"esc", "cancel"}, } + case ModeConfirmDelete: + return []helpEntry{ + {"y/enter", "confirm"}, + {"esc", "cancel"}, + } default: return []helpEntry{ {"j/k", "navigate"}, @@ -52,64 +57,96 @@ func (m Model) helpEntries() []helpEntry { {"p", "project"}, {"l", "list"}, {"s", "status"}, - {"X", "delete"}, + {"x", "delete"}, + {"X", "force delete"}, {"q", "quit"}, } } } -func (m Model) renderHelpbar() string { - var parts []string +func (m Model) helpParts() []string { + parts := make([]string, 0, len(m.helpEntries())) for _, e := range m.helpEntries() { parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc)) } + return parts +} + +func (m Model) helpSep() string { sep := m.styles.HelpSep.String() if sep == "" { - sep = " " + return " " } - return strings.Join(parts, sep) + return sep +} + +// wrapHelpParts packs styled help snippets into lines that fit within width. +func wrapHelpParts(parts []string, width int, sep string) []string { + if width < 1 { + width = 1 + } + sepW := lipgloss.Width(sep) + var lines []string + var cur string + + for _, p := range parts { + pw := lipgloss.Width(p) + if cur == "" { + cur = p + // Oversized single entry still gets its own line so nothing is dropped. + if pw > width { + lines = append(lines, cur) + cur = "" + } + continue + } + if lipgloss.Width(cur)+sepW+pw <= width { + cur = cur + sep + p + continue + } + lines = append(lines, cur) + cur = p + if pw > width { + lines = append(lines, cur) + cur = "" + } + } + if cur != "" { + lines = append(lines, cur) + } + if len(lines) == 0 { + return []string{""} + } + return lines } func (m Model) renderFooter() string { - help := m.renderHelpbar() - ver := m.styles.Version.Render(m.version) - width := m.width if width <= 0 { - width = lipgloss.Width(help) + lipgloss.Width(ver) + 1 + width = 80 } - helpW := lipgloss.Width(help) + ver := m.styles.Version.Render(m.version) verW := lipgloss.Width(ver) - gap := width - helpW - verW - if gap < 1 { - maxHelp := width - verW - 1 - if maxHelp < 0 { - maxHelp = 0 - } - help = truncateWidth(help, maxHelp) - helpW = lipgloss.Width(help) - gap = width - helpW - verW - if gap < 1 { - gap = 1 - } - } - return help + strings.Repeat(" ", gap) + ver -} + sep := m.helpSep() + parts := m.helpParts() -func truncateWidth(s string, max int) string { - if max <= 0 { - return "" - } - if lipgloss.Width(s) <= max { - return s - } - runes := []rune(s) - for len(runes) > 0 { - runes = runes[:len(runes)-1] - if lipgloss.Width(string(runes)) <= max-1 { - return string(runes) + "…" + // Wrap help across the full width so all bindings stay visible. + lines := wrapHelpParts(parts, width, sep) + + // Place version on the last line to the right when it fits; otherwise + // put it on an extra right-aligned line. + last := lines[len(lines)-1] + gap := width - lipgloss.Width(last) - verW + if gap >= 1 { + lines[len(lines)-1] = last + strings.Repeat(" ", gap) + ver + } else { + pad := width - verW + if pad < 0 { + pad = 0 } + lines = append(lines, strings.Repeat(" ", pad)+ver) } - return "" + + return strings.Join(lines, "\n") } diff --git a/internal/ui/keys.go b/internal/ui/keys.go index e0ff283..1f43be9 100644 --- a/internal/ui/keys.go +++ b/internal/ui/keys.go @@ -11,13 +11,15 @@ type KeyMap struct { Project key.Binding Board key.Binding Status key.Binding - Edit key.Binding - Delete key.Binding - Undo key.Binding - Redo key.Binding - Quit key.Binding - Enter key.Binding - Escape key.Binding + Edit key.Binding + Delete key.Binding // x — confirm first + DeleteForce key.Binding // X — immediate + ConfirmYes key.Binding + Undo key.Binding + Redo key.Binding + Quit key.Binding + Enter key.Binding + Escape key.Binding } // DefaultKeyMap returns the default key bindings. @@ -56,8 +58,16 @@ func DefaultKeyMap() KeyMap { key.WithHelp("e", "edit"), ), Delete: key.NewBinding( + key.WithKeys("x"), + key.WithHelp("x", "delete"), + ), + DeleteForce: key.NewBinding( key.WithKeys("X"), - key.WithHelp("X", "delete"), + key.WithHelp("X", "force delete"), + ), + ConfirmYes: key.NewBinding( + key.WithKeys("y", "Y"), + key.WithHelp("y", "confirm"), ), Undo: key.NewBinding( key.WithKeys("u", "ctrl+z"), @@ -84,7 +94,7 @@ func DefaultKeyMap() KeyMap { // ShortHelp implements help.KeyMap. func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.Undo, k.Redo, k.Quit} + return []key.Binding{k.Up, k.New, k.Search, k.Project, k.Board, k.Status, k.Edit, k.Delete, k.DeleteForce, k.Undo, k.Redo, k.Quit} } // FullHelp implements help.KeyMap. diff --git a/internal/ui/layout.go b/internal/ui/layout.go index 04c15e7..307ca40 100644 --- a/internal/ui/layout.go +++ b/internal/ui/layout.go @@ -63,6 +63,16 @@ func (m Model) renderBody(height int) string { menu := m.boardMenu.View() return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu) + case ModeConfirmDelete: + title := m.pendingDeleteTitle + if title == "" { + title = "this ticket" + } + prompt := m.styles.ErrorMsg.Render(fmt.Sprintf("Delete %q?", title)) + hint := m.styles.StatusMsg.Render("y/enter confirm · esc cancel") + content := prompt + "\n" + hint + return lipgloss.NewStyle().Height(height).Width(m.width).Render(content) + default: m.tickets.SetSize(m.width, height) return m.tickets.View()