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

This commit is contained in:
Rasmus Wejlgaard 2025-11-10 21:32:26 +00:00
parent 2e9980e73c
commit 2cd2c68cc4
5 changed files with 283 additions and 23 deletions

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

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