diff --git a/pkg/tui/components/issue_list.go b/pkg/tui/components/issue_list.go index da81d08..ceb0783 100644 --- a/pkg/tui/components/issue_list.go +++ b/pkg/tui/components/issue_list.go @@ -211,6 +211,7 @@ func NewIssueListFromIssues(app *app.App, issues []*models.Issue, width, height l.SetShowHelp(false) l.SetShowStatusBar(false) l.SetFilteringEnabled(true) + l.DisableQuitKeybindings() l.FilterInput.PromptStyle = styles.FilterPromptStyle l.FilterInput.Cursor.Style = styles.FilterStyle l.FilterInput.TextStyle = styles.FilterInputStyle diff --git a/pkg/tui/components/modals.go b/pkg/tui/components/modals.go index 5eee8d7..fbbf583 100644 --- a/pkg/tui/components/modals.go +++ b/pkg/tui/components/modals.go @@ -68,6 +68,22 @@ func RenderCreateIssue(width, height int, inputView string) string { return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, createBox) } +func RenderConfirmQuit(width, height int) string { + if width < 5 || height < 5 { + return "" + } + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Sure you want to quit?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("y = quit n/Esc = cancel"), + ) + confirmBoxWidth := modalBoxWidth(40, width) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, confirmBox) +} + func RenderConfirmDelete(width, height int, issueID string) string { if width < 5 || height < 5 { return "" @@ -157,6 +173,7 @@ func RenderChooseType(width, height int, issueID string) string { // no modal is active. func RenderModals( width, height int, + confirmingQuit bool, editingTitle bool, titleInputView string, editingDescription bool, descriptionInputView string, creatingIssue bool, createTitleInputView string, @@ -167,6 +184,9 @@ func RenderModals( editingAssignee bool, assigneeInputView string, mainView string, ) string { + if confirmingQuit { + return RenderConfirmQuit(width, height) + } if editingTitle { return RenderEditTitle(width, height, titleInputView) } diff --git a/pkg/tui/views/dashboard/keys.go b/pkg/tui/views/dashboard/keys.go index 0793cb8..0d9ca0d 100644 --- a/pkg/tui/views/dashboard/keys.go +++ b/pkg/tui/views/dashboard/keys.go @@ -87,8 +87,9 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { d.helpBar.ToggleHelp() d.logAction("tui toggled help") case key.Matches(msg, d.keyMap.Quit): + d.startConfirmQuit() d.logAction("tui quit requested") - return tea.Quit + return nil case key.Matches(msg, d.keyMap.SwitchWindow): d.ToggleFocusedWindow() case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToKanbanBoard): diff --git a/pkg/tui/views/dashboard/model.go b/pkg/tui/views/dashboard/model.go index bed24a1..0c8432b 100644 --- a/pkg/tui/views/dashboard/model.go +++ b/pkg/tui/views/dashboard/model.go @@ -45,6 +45,7 @@ type Model struct { confirmingDelete bool // true while confirming a delete deleteConfirmID string deleteConfirmIndex int + confirmingQuit bool // true while confirming quit choosingStatus bool statusIssueID string @@ -170,6 +171,37 @@ func (m *Model) startConfirmDelete(issueID string, index int) { m.deleteConfirmIndex = index } +func (m *Model) startConfirmQuit() { + m.confirmingQuit = true +} + +// cancelAllModals closes any open modal/input so quit can be triggered from anywhere. +func (m *Model) cancelAllModals() { + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + m.editingTitle = false + m.titleInput.Blur() + m.editingDescription = false + m.descriptionInput.Blur() + m.addingComment = false + m.commentInput.Blur() + m.commentInput.Reset() + m.editingAssignee = false + m.assigneeInput.Blur() + m.choosingStatus = false + m.statusIssueID = "" + m.choosingPriority = false + m.priorityIssueID = "" + m.choosingType = false + m.typeIssueID = "" + m.confirmingDelete = false + m.deleteConfirmID = "" + m.choosingCloseReason = false + m.closingOtherReason = false + m.closeReasonInput.Blur() +} + func (m *Model) startChooseStatus(selected ListIssue) { m.choosingStatus = true m.statusIssueID = selected.ID @@ -200,7 +232,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || m.choosingStatus || m.choosingPriority || m.confirmingDelete || - m.choosingType || m.editingAssignee || + m.confirmingQuit || m.choosingType || m.editingAssignee || m.choosingCloseReason || m.closingOtherReason } diff --git a/pkg/tui/views/dashboard/operations.go b/pkg/tui/views/dashboard/operations.go index 306d009..8a067d1 100644 --- a/pkg/tui/views/dashboard/operations.go +++ b/pkg/tui/views/dashboard/operations.go @@ -10,6 +10,7 @@ import ( "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/issues" "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/key" tea "github.com/charmbracelet/bubbletea" ) @@ -323,6 +324,29 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) case tea.KeyMsg: + // Always allow quit with q/ctrl+c, even when in inputs or other modals + if !m.confirmingQuit && (key.Matches(msg, m.keyMap.Quit) || msg.String() == "q" || msg.String() == "ctrl+c") { + m.cancelAllModals() + m.startConfirmQuit() + m.logAction("tui quit requested") + return m, nil + } + + if m.confirmingQuit { + switch msg.String() { + case "y", "Y", "ctrl+c": + m.logAction("tui confirmed quit") + m.confirmingQuit = false + return m, tea.Quit + case "n", "N", "esc": + m.logAction("tui canceled quit") + m.confirmingQuit = false + return m, nil + default: + return m, nil + } + } + if m.confirmingDelete { switch msg.String() { case "y", "Y": diff --git a/pkg/tui/views/dashboard/view.go b/pkg/tui/views/dashboard/view.go index 7824256..f582749 100644 --- a/pkg/tui/views/dashboard/view.go +++ b/pkg/tui/views/dashboard/view.go @@ -61,6 +61,19 @@ func (m *Model) View() string { mainView := lipgloss.JoinVertical(lipgloss.Left, header, content, footer) + if m.confirmingQuit { + confirmContent := lipgloss.JoinVertical(lipgloss.Left, + styles.LabelStyle.Render("Sure you want to quit?"), + lipgloss.NewStyle().Foreground(styles.FaintText).Render("y = quit n/Esc = cancel"), + ) + confirmBoxWidth := min(40, m.width-4) + confirmBox := styles.ContainerStyle. + Width(confirmBoxWidth). + BorderForeground(styles.PrimaryBorder). + Render(confirmContent) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, confirmBox) + } + if m.editingAssignee { editBoxWidth := min(60, m.width-4) m.assigneeInput.Width = editBoxWidth - 2 diff --git a/pkg/tui/views/kanban/keys.go b/pkg/tui/views/kanban/keys.go index df8345b..21e8642 100644 --- a/pkg/tui/views/kanban/keys.go +++ b/pkg/tui/views/kanban/keys.go @@ -59,7 +59,8 @@ func (d *Model) handleKeyMsg(msg tea.KeyMsg) tea.Cmd { case key.Matches(msg, d.keyMap.Help): d.helpBar.ToggleHelp() case key.Matches(msg, d.keyMap.Quit): - return tea.Quit + d.startConfirmQuit() + return nil case !d.IsInModal() && msg.String() == " ": return func() tea.Msg { return nil } // consume space case !d.IsInModal() && key.Matches(msg, d.keyMap.SwitchToDashboard): diff --git a/pkg/tui/views/kanban/model.go b/pkg/tui/views/kanban/model.go index 5a38984..715ff2d 100644 --- a/pkg/tui/views/kanban/model.go +++ b/pkg/tui/views/kanban/model.go @@ -47,6 +47,7 @@ type Model struct { confirmingDelete bool // true while confirming a delete deleteConfirmID string deleteConfirmIndex int + confirmingQuit bool // true while confirming quit choosingStatus bool // true while choosing a status statusIssueID string @@ -146,6 +147,34 @@ func (m *Model) startConfirmDelete(issueID string, index int) { m.deleteConfirmIndex = index } +func (m *Model) startConfirmQuit() { + m.confirmingQuit = true +} + +// cancelAllModals closes any open modal/input so quit can be triggered from anywhere. +func (m *Model) cancelAllModals() { + m.creatingIssue = false + m.createTitleInput.Blur() + m.createTitleInput.Reset() + m.editingTitle = false + m.titleInput.Blur() + m.editingDescription = false + m.descriptionInput.Blur() + m.editingAssignee = false + m.assigneeInput.Blur() + m.choosingStatus = false + m.statusIssueID = "" + m.choosingPriority = false + m.priorityIssueID = "" + m.choosingType = false + m.typeIssueID = "" + m.confirmingDelete = false + m.deleteConfirmID = "" + m.choosingCloseReason = false + m.closingOtherReason = false + m.closeReasonInput.Blur() +} + func (m *Model) startChooseStatus(selected ListIssue) { m.choosingStatus = true m.statusIssueID = selected.ID @@ -176,7 +205,7 @@ func (m *Model) Init() tea.Cmd { func (m *Model) IsInModal() bool { return m.editingTitle || m.creatingIssue || m.editingDescription || m.choosingStatus || m.choosingPriority || m.confirmingDelete || - m.choosingType || m.editingAssignee || + m.confirmingQuit || m.choosingType || m.editingAssignee || m.choosingCloseReason || m.closingOtherReason } diff --git a/pkg/tui/views/kanban/operations.go b/pkg/tui/views/kanban/operations.go index db09d9f..4e1af9b 100644 --- a/pkg/tui/views/kanban/operations.go +++ b/pkg/tui/views/kanban/operations.go @@ -6,6 +6,7 @@ import ( "github.com/LazyBachelor/LazyPM/internal/models" "github.com/LazyBachelor/LazyPM/pkg/tui/components" "github.com/LazyBachelor/LazyPM/pkg/tui/issues" + "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" ) @@ -259,6 +260,26 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { }) case tea.KeyMsg: + // Always allow quit with q/ctrl+c, even when in inputs or other modals + if !m.confirmingQuit && (key.Matches(msg, m.keyMap.Quit) || msg.String() == "q" || msg.String() == "ctrl+c") { + m.cancelAllModals() + m.startConfirmQuit() + return m, nil + } + + if m.confirmingQuit { + switch msg.String() { + case "y", "Y", "ctrl+c": + m.confirmingQuit = false + return m, tea.Quit + case "n", "N", "esc": + m.confirmingQuit = false + return m, nil + default: + return m, nil + } + } + if m.confirmingDelete { switch msg.String() { case "y", "Y": diff --git a/pkg/tui/views/kanban/view.go b/pkg/tui/views/kanban/view.go index 8e6a279..371df5a 100644 --- a/pkg/tui/views/kanban/view.go +++ b/pkg/tui/views/kanban/view.go @@ -82,6 +82,10 @@ func (m *Model) View() string { mainView = lipgloss.JoinVertical(lipgloss.Left, header, content, spacer, footer) } + if m.confirmingQuit { + return components.RenderConfirmQuit(m.width, m.height) + } + if m.choosingCloseReason { reasonContent := lipgloss.JoinVertical(lipgloss.Left, styles.LabelStyle.Render("Choose closing reason for "+m.closeReasonIssueID+":"), @@ -114,6 +118,7 @@ func (m *Model) View() string { return components.RenderModals( m.width, m.height, + m.confirmingQuit, m.editingTitle, m.titleInput.View(), m.editingDescription,