Improve delete UX and wrap the helpbar on narrow terminals.
Confirm with x, force-delete with X, and wrap keybinding hints so all shortcuts stay visible. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+39
-3
@@ -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):
|
||||
|
||||
+75
-38
@@ -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")
|
||||
}
|
||||
|
||||
+19
-9
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user