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 |
| `s` | Add sub-task |
| `D` | Delete item (with confirmation) |
| `R` | Rename item |
| `#` | Add/edit tags |
| `a` | Toggle agenda view |
| `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 |
| `r` | Toggle reorder mode |
| `shift+↑/↓` | Move item up/down |
| `sift+←/→` | Promote/demote item |
| `,` | Open settings |
| `ctrl+s` | Save |
| `ctrl+s` | Force save |
| `?` | Toggle help |
| `q` or `ctrl+c` | Quit |

View file

@ -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
}
@ -527,6 +542,9 @@ func (c *Config) GetAllKeybindings() map[string][]string {
"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,

View file

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

View file

@ -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"),

View file

@ -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
}

View file

@ -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)
@ -146,7 +148,13 @@ func (m uiModel) View() string {
for i, item := range items {
lineCount := 1 // The item itself
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 + " "
filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
@ -207,7 +215,13 @@ func (m uiModel) View() string {
// Render remaining notes
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 + " "
filteredNotes := filterLogbookDrawer(item.Notes)
wrappedNotes := wrapNoteLines(filteredNotes, m.width, noteIndent)
@ -231,7 +245,13 @@ func (m uiModel) View() string {
// Show notes if not folded
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 + " "
filteredNotes := filterLogbookDrawer(item.Notes)
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 {
var b strings.Builder
// Indentation for level
indent := strings.Repeat(" ", item.Level-1)
b.WriteString(indent)
// Indentation with subtle visual nesting guides
guideStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("245")) // Very subtle gray
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
if len(item.Children) > 0 || len(item.Notes) > 0 {
@ -1005,3 +1033,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()
}