fix: quality of life, renaming and better shuffling of items (#11)

* fix: renaming and pro- and de-motion of items

* adding guides for indentation

* updating readme
This commit is contained in:
Rasmus Wejlgaard 2025-11-10 21:48:28 +00:00 committed by GitHub
parent 2e9980e73c
commit 6b404cd722
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 318 additions and 30 deletions

View file

@ -105,6 +105,7 @@ Feel free to fork and create a pull request if there's any features missing for
| `c` | Capture new TODO | | `c` | Capture new TODO |
| `s` | Add sub-task | | `s` | Add sub-task |
| `D` | Delete item (with confirmation) | | `D` | Delete item (with confirmation) |
| `R` | Rename item |
| `#` | Add/edit tags | | `#` | Add/edit tags |
| `a` | Toggle agenda view | | `a` | Toggle agenda view |
| `i` | Clock in | | `i` | Clock in |
@ -114,8 +115,9 @@ Feel free to fork and create a pull request if there's any features missing for
| `e` | Set effort | | `e` | Set effort |
| `r` | Toggle reorder mode | | `r` | Toggle reorder mode |
| `shift+↑/↓` | Move item up/down | | `shift+↑/↓` | Move item up/down |
| `sift+←/→` | Promote/demote item |
| `,` | Open settings | | `,` | Open settings |
| `ctrl+s` | Save | | `ctrl+s` | Force save |
| `?` | Toggle help | | `?` | Toggle help |
| `q` or `ctrl+c` | Quit | | `q` or `ctrl+c` | Quit |

View file

@ -26,6 +26,9 @@ type KeybindingsConfig struct {
Right []string `toml:"right"` Right []string `toml:"right"`
ShiftUp []string `toml:"shift_up"` ShiftUp []string `toml:"shift_up"`
ShiftDown []string `toml:"shift_down"` ShiftDown []string `toml:"shift_down"`
ShiftLeft []string `toml:"shift_left"`
ShiftRight []string `toml:"shift_right"`
Rename []string `toml:"rename"`
CycleState []string `toml:"cycle_state"` CycleState []string `toml:"cycle_state"`
ToggleFold []string `toml:"toggle_fold"` ToggleFold []string `toml:"toggle_fold"`
EditNotes []string `toml:"edit_notes"` EditNotes []string `toml:"edit_notes"`
@ -103,6 +106,9 @@ func DefaultConfig() *Config {
Right: []string{"right", "l"}, Right: []string{"right", "l"},
ShiftUp: []string{"shift+up"}, ShiftUp: []string{"shift+up"},
ShiftDown: []string{"shift+down"}, ShiftDown: []string{"shift+down"},
ShiftLeft: []string{"shift+left"},
ShiftRight: []string{"shift+right"},
Rename: []string{"R"},
CycleState: []string{"t", " "}, CycleState: []string{"t", " "},
ToggleFold: []string{"tab"}, ToggleFold: []string{"tab"},
EditNotes: []string{"enter"}, EditNotes: []string{"enter"},
@ -255,6 +261,15 @@ func (c *Config) fillDefaults() {
if len(c.Keybindings.ShiftDown) == 0 { if len(c.Keybindings.ShiftDown) == 0 {
c.Keybindings.ShiftDown = defaults.Keybindings.ShiftDown 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 { if len(c.Keybindings.CycleState) == 0 {
c.Keybindings.CycleState = defaults.Keybindings.CycleState 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 // GetAllKeybindings returns a map of all keybindings
func (c *Config) GetAllKeybindings() map[string][]string { func (c *Config) GetAllKeybindings() map[string][]string {
return map[string][]string{ return map[string][]string{
"up": c.Keybindings.Up, "up": c.Keybindings.Up,
"down": c.Keybindings.Down, "down": c.Keybindings.Down,
"left": c.Keybindings.Left, "left": c.Keybindings.Left,
"right": c.Keybindings.Right, "right": c.Keybindings.Right,
"shift_up": c.Keybindings.ShiftUp, "shift_up": c.Keybindings.ShiftUp,
"shift_down": c.Keybindings.ShiftDown, "shift_down": c.Keybindings.ShiftDown,
"cycle_state": c.Keybindings.CycleState, "shift_left": c.Keybindings.ShiftLeft,
"toggle_fold": c.Keybindings.ToggleFold, "shift_right": c.Keybindings.ShiftRight,
"edit_notes": c.Keybindings.EditNotes, "rename": c.Keybindings.Rename,
"toggle_view": c.Keybindings.ToggleView, "cycle_state": c.Keybindings.CycleState,
"capture": c.Keybindings.Capture, "toggle_fold": c.Keybindings.ToggleFold,
"add_subtask": c.Keybindings.AddSubTask, "edit_notes": c.Keybindings.EditNotes,
"delete": c.Keybindings.Delete, "toggle_view": c.Keybindings.ToggleView,
"save": c.Keybindings.Save, "capture": c.Keybindings.Capture,
"add_subtask": c.Keybindings.AddSubTask,
"delete": c.Keybindings.Delete,
"save": c.Keybindings.Save,
"toggle_reorder": c.Keybindings.ToggleReorder, "toggle_reorder": c.Keybindings.ToggleReorder,
"clock_in": c.Keybindings.ClockIn, "clock_in": c.Keybindings.ClockIn,
"clock_out": c.Keybindings.ClockOut, "clock_out": c.Keybindings.ClockOut,
"set_deadline": c.Keybindings.SetDeadline, "set_deadline": c.Keybindings.SetDeadline,
"set_priority": c.Keybindings.SetPriority, "set_priority": c.Keybindings.SetPriority,
"set_effort": c.Keybindings.SetEffort, "set_effort": c.Keybindings.SetEffort,
"help": c.Keybindings.Help, "help": c.Keybindings.Help,
"quit": c.Keybindings.Quit, "quit": c.Keybindings.Quit,
"settings": c.Keybindings.Settings, "settings": c.Keybindings.Settings,
"tag_item": c.Keybindings.TagItem, "tag_item": c.Keybindings.TagItem,
} }
} }

View file

@ -27,6 +27,7 @@ const (
modeHelp modeHelp
modeSettings modeSettings
modeTagEdit modeTagEdit
modeRename
) )
type uiModel struct { type uiModel struct {

View file

@ -14,6 +14,9 @@ type keyMap struct {
Right key.Binding Right key.Binding
ShiftUp key.Binding ShiftUp key.Binding
ShiftDown key.Binding ShiftDown key.Binding
ShiftLeft key.Binding
ShiftRight key.Binding
Rename key.Binding
CycleState key.Binding CycleState key.Binding
ToggleView key.Binding ToggleView key.Binding
Quit key.Binding Quit key.Binding
@ -63,6 +66,18 @@ func newKeyMapFromConfig(cfg *config.Config) keyMap {
key.WithKeys(kb.ShiftDown...), key.WithKeys(kb.ShiftDown...),
key.WithHelp(formatKeyHelp(kb.ShiftDown), "move item down"), 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( CycleState: key.NewBinding(
key.WithKeys(kb.CycleState...), key.WithKeys(kb.CycleState...),
key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"), key.WithHelp(formatKeyHelp(kb.CycleState), "cycle todo state"),

View file

@ -40,6 +40,8 @@ func (m uiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m.updateSettingsAddState(msg) return m.updateSettingsAddState(msg)
case modeTagEdit: case modeTagEdit:
return m.updateTagEdit(msg) return m.updateTagEdit(msg)
case modeRename:
return m.updateRename(msg)
} }
switch msg := msg.(type) { 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): case key.Matches(msg, m.keys.ShiftDown):
m.moveItemDown() 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): case key.Matches(msg, m.keys.CycleState):
items := m.getVisibleItems() items := m.getVisibleItems()
if len(items) > 0 && m.cursor < len(items) { 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 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): case key.Matches(msg, m.keys.Capture):
m.mode = modeCapture m.mode = modeCapture
m.captureCursor = m.cursor // Store current cursor position m.captureCursor = m.cursor // Store current cursor position
@ -899,6 +918,151 @@ func (m *uiModel) swapItems(item1, item2 *model.Item) {
swapInList(m.orgFile.Items) 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) { func (m uiModel) updateHelp(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) { switch msg := msg.(type) {
case tea.WindowSizeMsg: case tea.WindowSizeMsg:
@ -979,3 +1143,45 @@ func (m *uiModel) updateTagEdit(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
return m, nil 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
}

View file

@ -92,6 +92,8 @@ func (m uiModel) View() string {
return m.viewSettingsAddState() return m.viewSettingsAddState()
case modeTagEdit: case modeTagEdit:
return m.viewTagEdit() return m.viewTagEdit()
case modeRename:
return m.viewRename()
} }
// Build footer (status + help) // Build footer (status + help)
@ -146,7 +148,13 @@ func (m uiModel) View() string {
for i, item := range items { for i, item := range items {
lineCount := 1 // The item itself lineCount := 1 // The item itself
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level) // Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
var notePrefix strings.Builder
for j := 1; j <= item.Level; j++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
@ -207,7 +215,13 @@ func (m uiModel) View() string {
// Render remaining notes // Render remaining notes
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level) // Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245"))
var notePrefix strings.Builder
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
@ -231,7 +245,13 @@ func (m uiModel) View() string {
// Show notes if not folded // Show notes if not folded
if !item.Folded && len(item.Notes) > 0 && m.mode == modeList { if !item.Folded && len(item.Notes) > 0 && m.mode == modeList {
indent := strings.Repeat(" ", item.Level) // Build subtle visual guides for notes
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("235"))
var notePrefix strings.Builder
for i := 1; i <= item.Level; i++ {
notePrefix.WriteString(guideStyle.Render("· "))
}
indent := notePrefix.String()
noteIndent := indent + " " noteIndent := indent + " "
filteredNotes := filterLogbookDrawer(item.Notes) filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent) wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
@ -873,9 +893,17 @@ func wrapText(text string, width int, indent string) []string {
func (m uiModel) renderItem(item *model.Item, isCursor bool) string { func (m uiModel) renderItem(item *model.Item, isCursor bool) string {
var b strings.Builder var b strings.Builder
// Indentation for level // Indentation with subtle visual nesting guides
indent := strings.Repeat(" ", item.Level-1) guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // Very subtle gray
b.WriteString(indent) for i := 1; i < item.Level; i++ {
if i == item.Level-1 {
// Last level before the item - use subtle dot connector
b.WriteString(guideStyle.Render("· "))
} else {
// Parent levels - use subtle dot
b.WriteString(guideStyle.Render("· "))
}
}
// Fold indicator // Fold indicator
if len(item.Children) > 0 || len(item.Notes) > 0 { if len(item.Children) > 0 || len(item.Notes) > 0 {
@ -1005,3 +1033,21 @@ func (m uiModel) viewTagEdit() string {
return content.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()
}