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
|
ModeProjects
|
||||||
ModeStatus
|
ModeStatus
|
||||||
ModeBoard
|
ModeBoard
|
||||||
|
ModeConfirmDelete
|
||||||
)
|
)
|
||||||
|
|
||||||
// Model is the root Bubble Tea model.
|
// Model is the root Bubble Tea model.
|
||||||
@@ -60,8 +61,9 @@ type Model struct {
|
|||||||
keys KeyMap
|
keys KeyMap
|
||||||
styles Styles
|
styles Styles
|
||||||
version string
|
version string
|
||||||
allTickets []ticket.Ticket
|
allTickets []ticket.Ticket
|
||||||
creatingProject bool
|
creatingProject bool
|
||||||
|
pendingDeleteTitle string
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates the root UI model.
|
// 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)
|
return m.updateStatus(msg)
|
||||||
case ModeBoard:
|
case ModeBoard:
|
||||||
return m.updateBoard(msg)
|
return m.updateBoard(msg)
|
||||||
|
case ModeConfirmDelete:
|
||||||
|
return m.updateConfirmDelete(msg)
|
||||||
default:
|
default:
|
||||||
return m.updateList(msg)
|
return m.updateList(msg)
|
||||||
}
|
}
|
||||||
@@ -212,7 +216,7 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.mode = ModeBoard
|
m.mode = ModeBoard
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
case key.Matches(msg, m.keys.Delete):
|
case key.Matches(msg, m.keys.DeleteForce):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
if err := m.deleteSelected(); err != nil {
|
if err := m.deleteSelected(); err != nil {
|
||||||
m.setError(err)
|
m.setError(err)
|
||||||
@@ -221,6 +225,17 @@ func (m Model) updateList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
}
|
}
|
||||||
return m, nil
|
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):
|
case key.Matches(msg, m.keys.Undo):
|
||||||
m.clearStatus()
|
m.clearStatus()
|
||||||
if err := m.undo(); err != nil {
|
if err := m.undo(); err != nil {
|
||||||
@@ -324,6 +339,27 @@ func (m Model) updateBoard(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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) {
|
func (m Model) updateInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
switch {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Escape):
|
case key.Matches(msg, m.keys.Escape):
|
||||||
|
|||||||
+75
-38
@@ -42,6 +42,11 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"enter", "apply"},
|
{"enter", "apply"},
|
||||||
{"esc", "cancel"},
|
{"esc", "cancel"},
|
||||||
}
|
}
|
||||||
|
case ModeConfirmDelete:
|
||||||
|
return []helpEntry{
|
||||||
|
{"y/enter", "confirm"},
|
||||||
|
{"esc", "cancel"},
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return []helpEntry{
|
return []helpEntry{
|
||||||
{"j/k", "navigate"},
|
{"j/k", "navigate"},
|
||||||
@@ -52,64 +57,96 @@ func (m Model) helpEntries() []helpEntry {
|
|||||||
{"p", "project"},
|
{"p", "project"},
|
||||||
{"l", "list"},
|
{"l", "list"},
|
||||||
{"s", "status"},
|
{"s", "status"},
|
||||||
{"X", "delete"},
|
{"x", "delete"},
|
||||||
|
{"X", "force delete"},
|
||||||
{"q", "quit"},
|
{"q", "quit"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) renderHelpbar() string {
|
func (m Model) helpParts() []string {
|
||||||
var parts []string
|
parts := make([]string, 0, len(m.helpEntries()))
|
||||||
for _, e := range m.helpEntries() {
|
for _, e := range m.helpEntries() {
|
||||||
parts = append(parts, m.styles.HelpKey.Render(e.key)+" "+m.styles.HelpDesc.Render(e.desc))
|
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()
|
sep := m.styles.HelpSep.String()
|
||||||
if sep == "" {
|
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 {
|
func (m Model) renderFooter() string {
|
||||||
help := m.renderHelpbar()
|
|
||||||
ver := m.styles.Version.Render(m.version)
|
|
||||||
|
|
||||||
width := m.width
|
width := m.width
|
||||||
if width <= 0 {
|
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)
|
verW := lipgloss.Width(ver)
|
||||||
gap := width - helpW - verW
|
sep := m.helpSep()
|
||||||
if gap < 1 {
|
parts := m.helpParts()
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func truncateWidth(s string, max int) string {
|
// Wrap help across the full width so all bindings stay visible.
|
||||||
if max <= 0 {
|
lines := wrapHelpParts(parts, width, sep)
|
||||||
return ""
|
|
||||||
}
|
// Place version on the last line to the right when it fits; otherwise
|
||||||
if lipgloss.Width(s) <= max {
|
// put it on an extra right-aligned line.
|
||||||
return s
|
last := lines[len(lines)-1]
|
||||||
}
|
gap := width - lipgloss.Width(last) - verW
|
||||||
runes := []rune(s)
|
if gap >= 1 {
|
||||||
for len(runes) > 0 {
|
lines[len(lines)-1] = last + strings.Repeat(" ", gap) + ver
|
||||||
runes = runes[:len(runes)-1]
|
} else {
|
||||||
if lipgloss.Width(string(runes)) <= max-1 {
|
pad := width - verW
|
||||||
return string(runes) + "…"
|
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
|
Project key.Binding
|
||||||
Board key.Binding
|
Board key.Binding
|
||||||
Status key.Binding
|
Status key.Binding
|
||||||
Edit key.Binding
|
Edit key.Binding
|
||||||
Delete key.Binding
|
Delete key.Binding // x — confirm first
|
||||||
Undo key.Binding
|
DeleteForce key.Binding // X — immediate
|
||||||
Redo key.Binding
|
ConfirmYes key.Binding
|
||||||
Quit key.Binding
|
Undo key.Binding
|
||||||
Enter key.Binding
|
Redo key.Binding
|
||||||
Escape key.Binding
|
Quit key.Binding
|
||||||
|
Enter key.Binding
|
||||||
|
Escape key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultKeyMap returns the default key bindings.
|
// DefaultKeyMap returns the default key bindings.
|
||||||
@@ -56,8 +58,16 @@ func DefaultKeyMap() KeyMap {
|
|||||||
key.WithHelp("e", "edit"),
|
key.WithHelp("e", "edit"),
|
||||||
),
|
),
|
||||||
Delete: key.NewBinding(
|
Delete: key.NewBinding(
|
||||||
|
key.WithKeys("x"),
|
||||||
|
key.WithHelp("x", "delete"),
|
||||||
|
),
|
||||||
|
DeleteForce: key.NewBinding(
|
||||||
key.WithKeys("X"),
|
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(
|
Undo: key.NewBinding(
|
||||||
key.WithKeys("u", "ctrl+z"),
|
key.WithKeys("u", "ctrl+z"),
|
||||||
@@ -84,7 +94,7 @@ func DefaultKeyMap() KeyMap {
|
|||||||
|
|
||||||
// ShortHelp implements help.KeyMap.
|
// ShortHelp implements help.KeyMap.
|
||||||
func (k KeyMap) ShortHelp() []key.Binding {
|
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.
|
// FullHelp implements help.KeyMap.
|
||||||
|
|||||||
@@ -63,6 +63,16 @@ func (m Model) renderBody(height int) string {
|
|||||||
menu := m.boardMenu.View()
|
menu := m.boardMenu.View()
|
||||||
return lipgloss.NewStyle().Height(height).Width(m.width).Render(menu)
|
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:
|
default:
|
||||||
m.tickets.SetSize(m.width, height)
|
m.tickets.SetSize(m.width, height)
|
||||||
return m.tickets.View()
|
return m.tickets.View()
|
||||||
|
|||||||
Reference in New Issue
Block a user