From 2cd2c68cc4be09baead2fe6a10dac4e684809e0d Mon Sep 17 00:00:00 2001 From: Rasmus Wejlgaard Date: Mon, 10 Nov 2025 21:32:26 +0000 Subject: [PATCH] fix: renaming and pro- and de-motion of items --- internal/config/config.go | 64 +++++++----- internal/ui/app.go | 1 + internal/ui/keybindings.go | 15 +++ internal/ui/modes.go | 206 +++++++++++++++++++++++++++++++++++++ internal/ui/views.go | 20 ++++ 5 files changed, 283 insertions(+), 23 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 5d82f02..9541938 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,6 +26,9 @@ type KeybindingsConfig struct { Right []string `toml:"right"` ShiftUp []string `toml:"shift_up"` ShiftDown []string `toml:"shift_down"` + ShiftLeft []string `toml:"shift_left"` + ShiftRight []string `toml:"shift_right"` + Rename []string `toml:"rename"` CycleState []string `toml:"cycle_state"` ToggleFold []string `toml:"toggle_fold"` EditNotes []string `toml:"edit_notes"` @@ -103,6 +106,9 @@ func DefaultConfig() *Config { Right: []string{"right", "l"}, ShiftUp: []string{"shift+up"}, ShiftDown: []string{"shift+down"}, + ShiftLeft: []string{"shift+left"}, + ShiftRight: []string{"shift+right"}, + Rename: []string{"R"}, CycleState: []string{"t", " "}, ToggleFold: []string{"tab"}, EditNotes: []string{"enter"}, @@ -255,6 +261,15 @@ func (c *Config) fillDefaults() { if len(c.Keybindings.ShiftDown) == 0 { c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown } + if len(c.Keybindings.ShiftLeft) == 0 { + c.Keybindings.ShiftLeft = defaults.Keybindings.ShiftLeft + } + if len(c.Keybindings.ShiftRight) == 0 { + c.Keybindings.ShiftRight = defaults.Keybindings.ShiftRight + } + if len(c.Keybindings.Rename) == 0 { + c.Keybindings.Rename = defaults.Keybindings.Rename + } if len(c.Keybindings.CycleState) == 0 { c.Keybindings.CycleState = defaults.Keybindings.CycleState } @@ -521,30 +536,33 @@ func (c *Config) UpdateKeybinding(action string, keys []string) error { // GetAllKeybindings returns a map of all keybindings func (c *Config) GetAllKeybindings() map[string][]string { return map[string][]string{ - "up": c.Keybindings.Up, - "down": c.Keybindings.Down, - "left": c.Keybindings.Left, - "right": c.Keybindings.Right, - "shift_up": c.Keybindings.ShiftUp, - "shift_down": c.Keybindings.ShiftDown, - "cycle_state": c.Keybindings.CycleState, - "toggle_fold": c.Keybindings.ToggleFold, - "edit_notes": c.Keybindings.EditNotes, - "toggle_view": c.Keybindings.ToggleView, - "capture": c.Keybindings.Capture, - "add_subtask": c.Keybindings.AddSubTask, - "delete": c.Keybindings.Delete, - "save": c.Keybindings.Save, + "up": c.Keybindings.Up, + "down": c.Keybindings.Down, + "left": c.Keybindings.Left, + "right": c.Keybindings.Right, + "shift_up": c.Keybindings.ShiftUp, + "shift_down": c.Keybindings.ShiftDown, + "shift_left": c.Keybindings.ShiftLeft, + "shift_right": c.Keybindings.ShiftRight, + "rename": c.Keybindings.Rename, + "cycle_state": c.Keybindings.CycleState, + "toggle_fold": c.Keybindings.ToggleFold, + "edit_notes": c.Keybindings.EditNotes, + "toggle_view": c.Keybindings.ToggleView, + "capture": c.Keybindings.Capture, + "add_subtask": c.Keybindings.AddSubTask, + "delete": c.Keybindings.Delete, + "save": c.Keybindings.Save, "toggle_reorder": c.Keybindings.ToggleReorder, - "clock_in": c.Keybindings.ClockIn, - "clock_out": c.Keybindings.ClockOut, - "set_deadline": c.Keybindings.SetDeadline, - "set_priority": c.Keybindings.SetPriority, - "set_effort": c.Keybindings.SetEffort, - "help": c.Keybindings.Help, - "quit": c.Keybindings.Quit, - "settings": c.Keybindings.Settings, - "tag_item": c.Keybindings.TagItem, + "clock_in": c.Keybindings.ClockIn, + "clock_out": c.Keybindings.ClockOut, + "set_deadline": c.Keybindings.SetDeadline, + "set_priority": c.Keybindings.SetPriority, + "set_effort": c.Keybindings.SetEffort, + "help": c.Keybindings.Help, + "quit": c.Keybindings.Quit, + "settings": c.Keybindings.Settings, + "tag_item": c.Keybindings.TagItem, } } diff --git a/internal/ui/app.go b/internal/ui/app.go index 764b299..9f1bd30 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -27,6 +27,7 @@ const ( modeHelp modeSettings modeTagEdit + modeRename ) type uiModel struct { diff --git a/internal/ui/keybindings.go b/internal/ui/keybindings.go index 65cd3af..9bc160c 100644 --- a/internal/ui/keybindings.go +++ b/internal/ui/keybindings.go @@ -14,6 +14,9 @@ type keyMap struct { Right key.Binding ShiftUp key.Binding ShiftDown key.Binding + ShiftLeft key.Binding + ShiftRight key.Binding + Rename key.Binding CycleState key.Binding ToggleView key.Binding Quit key.Binding @@ -63,6 +66,18 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap { key.WithKeys(kb.ShiftDown...), key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"), ), + ShiftLeft: key.NewBinding( + key.WithKeys(kb.ShiftLeft...), + key.WithHelp(formatKeyHelp(kb.ShiftLeft), "promote item"), + ), + ShiftRight: key.NewBinding( + key.WithKeys(kb.ShiftRight...), + key.WithHelp(formatKeyHelp(kb.ShiftRight), "demote item"), + ), + Rename: key.NewBinding( + key.WithKeys(kb.Rename...), + key.WithHelp(formatKeyHelp(kb.Rename), "rename item"), + ), CycleState: key.NewBinding( key.WithKeys(kb.CycleState...), key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"), diff --git a/internal/ui/modes.go b/internal/ui/modes.go index 0282bcd..46d1d57 100644 --- a/internal/ui/modes.go +++ b/internal/ui/modes.go @@ -40,6 +40,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateSettingsAddState(msg) case modeTagEdit: return m.updateTagEdit(msg) + case modeRename: + return m.updateRename(msg) } switch msg := msg.(type) { @@ -122,6 +124,12 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case key.Matches(msg, m.keys.ShiftDown): m.moveItemDown() + case key.Matches(msg, m.keys.ShiftLeft): + m.promoteItem() + + case key.Matches(msg, m.keys.ShiftRight): + m.demoteItem() + case key.Matches(msg, m.keys.CycleState): items := m.getVisibleItems() if len(items) > 0 && m.cursor < len(items) { @@ -182,6 +190,17 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, textinput.Blink } + case key.Matches(msg, m.keys.Rename): + items := m.getVisibleItems() + if len(items) > 0 && m.cursor < len(items) { + m.editingItem = items[m.cursor] + m.mode = modeRename + m.textinput.SetValue(items[m.cursor].Title) + m.textinput.Placeholder = "Item title" + m.textinput.Focus() + return m, textinput.Blink + } + case key.Matches(msg, m.keys.Capture): m.mode = modeCapture m.captureCursor = m.cursor // Store current cursor position @@ -899,6 +918,151 @@ func (m *uiModel) swapItems(item1, item2 *model.Item) { swapInList(m.orgFile.Items) } +func (m *uiModel) promoteItem() { + items := m.getVisibleItems() + if len(items) == 0 || m.cursor >= len(items) { + return + } + + currentItem := items[m.cursor] + + // Can't promote a top-level item + if currentItem.Level <= 1 { + m.setStatus("Cannot promote - already at top level") + return + } + + // Find the parent of this item + parent := m.findParent(currentItem) + if parent == nil { + m.setStatus("Cannot promote - no parent found") + return + } + + // Remove item from parent's children + for i, child := range parent.Children { + if child == currentItem { + parent.Children = append(parent.Children[:i], parent.Children[i+1:]...) + break + } + } + + // Find grandparent to insert this item after the parent + grandparent := m.findParent(parent) + if grandparent != nil { + // Insert after parent in grandparent's children + for i, child := range grandparent.Children { + if child == parent { + // Decrease level and update all descendants + m.adjustItemLevels(currentItem, -1) + grandparent.Children = append(grandparent.Children[:i+1], append([]*model.Item{currentItem}, grandparent.Children[i+1:]...)...) + break + } + } + } else { + // Parent is at top level, insert after parent in m.orgFile.Items + for i, item := range m.orgFile.Items { + if item == parent { + // Decrease level and update all descendants + m.adjustItemLevels(currentItem, -1) + m.orgFile.Items = append(m.orgFile.Items[:i+1], append([]*model.Item{currentItem}, m.orgFile.Items[i+1:]...)...) + break + } + } + } + + m.setStatus("Item promoted") + + // Update cursor to follow the item + items = m.getVisibleItems() + for i, item := range items { + if item == currentItem { + m.cursor = i + break + } + } +} + +func (m *uiModel) demoteItem() { + items := m.getVisibleItems() + if len(items) == 0 || m.cursor >= len(items) { + return + } + + currentItem := items[m.cursor] + + // Find the previous sibling to make this item its child + prevSibling := m.findPreviousSibling(currentItem) + if prevSibling == nil { + m.setStatus("Cannot demote - no previous sibling") + return + } + + // Remove item from its current parent's children + parent := m.findParent(currentItem) + if parent != nil { + for i, child := range parent.Children { + if child == currentItem { + parent.Children = append(parent.Children[:i], parent.Children[i+1:]...) + break + } + } + } else { + // Item is at top level + for i, item := range m.orgFile.Items { + if item == currentItem { + m.orgFile.Items = append(m.orgFile.Items[:i], m.orgFile.Items[i+1:]...) + break + } + } + } + + // Increase level and update all descendants + m.adjustItemLevels(currentItem, 1) + + // Add as child of previous sibling + prevSibling.Children = append(prevSibling.Children, currentItem) + prevSibling.Folded = false // Unfold to show the demoted item + + m.setStatus("Item demoted") + + // Update cursor to follow the item + items = m.getVisibleItems() + for i, item := range items { + if item == currentItem { + m.cursor = i + break + } + } +} + +func (m *uiModel) findParent(target *model.Item) *model.Item { + var findInList func([]*model.Item) *model.Item + findInList = func(items []*model.Item) *model.Item { + for _, item := range items { + // Check if target is a direct child + for _, child := range item.Children { + if child == target { + return item + } + } + // Recursively check children + if result := findInList(item.Children); result != nil { + return result + } + } + return nil + } + return findInList(m.orgFile.Items) +} + +func (m *uiModel) adjustItemLevels(item *model.Item, delta int) { + item.Level += delta + for _, child := range item.Children { + m.adjustItemLevels(child, delta) + } +} + func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -979,3 +1143,45 @@ func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil } + +// updateRename handles item rename mode +func (m *uiModel) updateRename(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch { + case key.Matches(msg, m.keys.Quit): + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + return m, nil + + case msg.Type == tea.KeyEnter: + if m.editingItem != nil { + newTitle := strings.TrimSpace(m.textinput.Value()) + if newTitle != "" { + m.editingItem.Title = newTitle + m.setStatus("Item renamed") + } else { + m.setStatus("Cannot rename to empty title") + } + } + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + return m, nil + + case msg.Type == tea.KeyEsc: + m.mode = modeList + m.textinput.Blur() + m.editingItem = nil + m.setStatus("Cancelled") + return m, nil + + default: + var cmd tea.Cmd + m.textinput, cmd = m.textinput.Update(msg) + return m, cmd + } + } + return m, nil +} diff --git a/internal/ui/views.go b/internal/ui/views.go index c2f46a3..259cb91 100644 --- a/internal/ui/views.go +++ b/internal/ui/views.go @@ -92,6 +92,8 @@ func (m uiModel) View() string { return m.viewSettingsAddState() case modeTagEdit: return m.viewTagEdit() + case modeRename: + return m.viewRename() } // Build footer (status + help) @@ -1005,3 +1007,21 @@ func (m uiModel) viewTagEdit() string { return content.String() } + +// viewRename renders the rename item view +func (m uiModel) viewRename() string { + var content strings.Builder + + content.WriteString(m.styles.titleStyle.Render("Rename Item") + "\n\n") + + if m.editingItem != nil { + content.WriteString(m.styles.statusStyle.Render(fmt.Sprintf("Current: %s", m.editingItem.Title)) + "\n\n") + } + + content.WriteString(m.textinput.View() + "\n\n") + + content.WriteString(m.styles.statusStyle.Render("Enter new title for the item") + "\n\n") + content.WriteString(m.styles.statusStyle.Render("Press Enter to save • ESC to cancel") + "\n") + + return content.String() +}