diff --git a/.doc/doki/doc/config.md b/.doc/doki/doc/config.md index ac9e1dc..53579c6 100644 --- a/.doc/doki/doc/config.md +++ b/.doc/doki/doc/config.md @@ -114,6 +114,7 @@ statuses: views: - name: Kanban + description: "Move tiki to new status, search, create or delete" foreground: "#87ceeb" background: "#25496a" key: "F1" @@ -132,6 +133,7 @@ views: action: status = 'done' sort: Priority, CreatedAt - name: Backlog + description: "Tasks waiting to be picked up, sorted by priority" foreground: "#5fff87" background: "#0b3d2e" key: "F3" @@ -145,6 +147,7 @@ views: action: status = 'ready' sort: Priority, ID - name: Recent + description: "Tasks changed in the last 24 hours, most recent first" foreground: "#f4d6a6" background: "#5a3d1b" key: Ctrl-R @@ -154,6 +157,7 @@ views: filter: NOW - UpdatedAt < 24hours sort: UpdatedAt DESC - name: Roadmap + description: "Epics organized by Now, Next, and Later horizons" foreground: "#e2e8f0" background: "#2a5f5a" key: "F4" @@ -173,6 +177,7 @@ views: sort: Priority, Points DESC view: expanded - name: Help + description: "Keyboard shortcuts, navigation, and usage guide" type: doki fetcher: internal text: "Help" @@ -180,6 +185,7 @@ views: background: "#003399" key: "?" - name: Docs + description: "Project notes and documentation files" type: doki fetcher: file url: "index.md" diff --git a/.doc/doki/doc/plugin.md b/.doc/doki/doc/plugin.md index f9377e5..e22f732 100644 --- a/.doc/doki/doc/plugin.md +++ b/.doc/doki/doc/plugin.md @@ -51,6 +51,7 @@ how Backlog is defined: ```yaml views: - name: Backlog + description: "Tasks waiting to be picked up, sorted by priority" foreground: "#5fff87" background: "#0b3d2e" key: "F3" @@ -67,13 +68,14 @@ views: that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane. The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready` -You define the name, caption colors, hotkey, tiki filter and sorting. Save this into a `workflow.yaml` file in the config directory +You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory Likewise the documentation is just a plugin: ```yaml views: - name: Docs + description: "Project notes and documentation files" type: doki fetcher: file url: "index.md" diff --git a/config/colors.go b/config/colors.go index fad2404..4d6eb73 100644 --- a/config/colors.go +++ b/config/colors.go @@ -69,10 +69,11 @@ type ColorConfig struct { BurndownHeaderGradientTo Gradient // Header view colors - HeaderInfoLabel string // tview color string like "[orange]" - HeaderInfoValue string // tview color string like "[white]" - HeaderKeyBinding string // tview color string like "[yellow]" - HeaderKeyText string // tview color string like "[white]" + HeaderInfoLabel string // tview color string for view name (bold) + HeaderInfoSeparator string // tview color string for horizontal rule below name + HeaderInfoDesc string // tview color string for view description + HeaderKeyBinding string // tview color string like "[yellow]" + HeaderKeyText string // tview color string like "[white]" // Points visual bar colors PointsFilledColor string // tview color string for filled segments @@ -93,6 +94,7 @@ type ColorConfig struct { StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e" StatuslineMessageFg string // hex color for right-section message text, e.g. "#ff8787" StatuslineMessageBg string // hex color for right-section message background, e.g. "#3a3a3a" + StatuslineFillBg string // hex color for empty statusline area between segments } // DefaultColors returns the default color configuration @@ -178,10 +180,11 @@ func DefaultColors() *ColorConfig { PointsUnfilledColor: "[#5f6982]", // Gray for unfilled segments // Header - HeaderInfoLabel: "[orange]", - HeaderInfoValue: "[#cccccc]", - HeaderKeyBinding: "[yellow]", - HeaderKeyText: "[white]", + HeaderInfoLabel: "[orange]", + HeaderInfoSeparator: "[#555555]", + HeaderInfoDesc: "[#888888]", + HeaderKeyBinding: "[yellow]", + HeaderKeyText: "[white]", // Header context help actions HeaderActionGlobalKeyColor: "#ffff00", // yellow for global actions @@ -198,6 +201,7 @@ func DefaultColors() *ColorConfig { StatuslineAccentFg: "#1c1c2e", // dark text on accent StatuslineMessageFg: "#ff8787", // soft red for error/message text StatuslineMessageBg: "#3a3a3a", // dark gray for message background + StatuslineFillBg: "#2a2a45", // muted dark indigo fill for empty statusline area } } diff --git a/config/default_workflow.yaml b/config/default_workflow.yaml index bc99b16..ca6ece1 100644 --- a/config/default_workflow.yaml +++ b/config/default_workflow.yaml @@ -22,6 +22,7 @@ statuses: views: - name: Kanban + description: "Move tiki to new status, search, create or delete" default: true foreground: "#87ceeb" background: "#25496a" @@ -41,6 +42,7 @@ views: action: status = 'done' sort: Priority, CreatedAt - name: Backlog + description: "Tasks waiting to be picked up, sorted by priority" foreground: "#5fff87" background: "#0b3d2e" key: "F3" @@ -54,6 +56,7 @@ views: action: status = 'ready' sort: Priority, ID - name: Recent + description: "Tasks changed in the last 24 hours, most recent first" foreground: "#f4d6a6" background: "#5a3d1b" key: Ctrl-R @@ -63,6 +66,7 @@ views: filter: NOW - UpdatedAt < 24hours sort: UpdatedAt DESC - name: Roadmap + description: "Epics organized by Now, Next, and Later horizons" foreground: "#e2e8f0" background: "#2a5f5a" key: "F4" @@ -82,6 +86,7 @@ views: sort: Priority, Points DESC view: expanded - name: Help + description: "Keyboard shortcuts, navigation, and usage guide" type: doki fetcher: internal text: "Help" @@ -89,6 +94,7 @@ views: background: "#003399" key: "?" - name: Docs + description: "Project notes and documentation files" type: doki fetcher: file url: "index.md" diff --git a/controller/input_router.go b/controller/input_router.go index 77f9332..a8c7aef 100644 --- a/controller/input_router.go +++ b/controller/input_router.go @@ -216,6 +216,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool { pluginDef := &plugin.TikiPlugin{ BasePlugin: plugin.BasePlugin{ Name: name, + Description: model.DepsEditorViewDesc, ConfigIndex: -1, Type: "tiki", Background: config.DepsEditorBackground, diff --git a/controller/interfaces.go b/controller/interfaces.go index 4d307a8..e9868de 100644 --- a/controller/interfaces.go +++ b/controller/interfaces.go @@ -271,9 +271,15 @@ type RecurrencePartNavigable interface { MoveRecurrencePartRight() bool } -// StatsProvider is a view that provides statistics for the header +// ViewInfoProvider is a view that provides its name and description for the header info section +type ViewInfoProvider interface { + GetViewName() string + GetViewDescription() string +} + +// StatsProvider is a view that provides statistics for the statusline type StatsProvider interface { - // GetStats returns stats to display in the header for this view + // GetStats returns stats to display in the statusline for this view GetStats() []store.Stat } diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index 2389694..c9a99e6 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -98,7 +98,6 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { // Phase 5: Model initialization headerConfig, layoutModel := InitHeaderAndLayoutModels() - InitHeaderBaseStats(headerConfig, tikiStore) statuslineConfig := InitStatuslineModel(tikiStore) // Phase 6: Plugin system diff --git a/internal/bootstrap/models.go b/internal/bootstrap/models.go index f838ce3..8d5580d 100644 --- a/internal/bootstrap/models.go +++ b/internal/bootstrap/models.go @@ -20,16 +20,6 @@ func InitHeaderAndLayoutModels() (*model.HeaderConfig, *model.LayoutModel) { return headerConfig, layoutModel } -// InitHeaderBaseStats initializes base header stats that are always visible regardless of view. -func InitHeaderBaseStats(headerConfig *model.HeaderConfig, tikiStore *tikistore.TikiStore) { - headerConfig.SetBaseStat("Version", config.Version, 0) - headerConfig.SetBaseStat("Mode", "kanban", 1) - headerConfig.SetBaseStat("Store", "local", 2) - for _, stat := range tikiStore.GetStats() { - headerConfig.SetBaseStat(stat.Name, stat.Value, stat.Order) - } -} - // InitStatuslineModel creates and populates the statusline config with base stats (version, branch, user). func InitStatuslineModel(tikiStore *tikistore.TikiStore) *model.StatuslineConfig { cfg := model.NewStatuslineConfig() diff --git a/model/header_config.go b/model/header_config.go index 2e99fc9..dbdc04c 100644 --- a/model/header_config.go +++ b/model/header_config.go @@ -1,7 +1,6 @@ package model import ( - "maps" "sync" "github.com/boolean-maybe/tiki/store" @@ -20,7 +19,7 @@ type HeaderAction struct { ShowInHeader bool } -// StatValue represents a single stat entry for the header +// StatValue represents a single stat entry for the statusline type StatValue struct { Value string Priority int @@ -32,11 +31,11 @@ type HeaderConfig struct { mu sync.RWMutex // Content state - viewActions []HeaderAction - pluginActions []HeaderAction - baseStats map[string]StatValue // global stats (version, store, user, branch, etc.) - viewStats map[string]StatValue // view-specific stats (e.g., board "Total") - burndown []store.BurndownPoint + viewActions []HeaderAction + pluginActions []HeaderAction + viewName string // current view name for info section + viewDescription string // current view description for info section + burndown []store.BurndownPoint // Visibility state visible bool // current header visibility (may be overridden by fullscreen view) @@ -50,8 +49,6 @@ type HeaderConfig struct { // NewHeaderConfig creates a new header config with default state func NewHeaderConfig() *HeaderConfig { return &HeaderConfig{ - baseStats: make(map[string]StatValue), - viewStats: make(map[string]StatValue), visible: true, userPreference: true, listeners: make(map[int]func()), @@ -89,39 +86,27 @@ func (hc *HeaderConfig) GetPluginActions() []HeaderAction { return hc.pluginActions } -// SetBaseStat sets a global stat (displayed in all views) -func (hc *HeaderConfig) SetBaseStat(key, value string, priority int) { +// SetViewInfo sets the current view name and description for the header info section +func (hc *HeaderConfig) SetViewInfo(name, description string) { hc.mu.Lock() - hc.baseStats[key] = StatValue{Value: value, Priority: priority} + hc.viewName = name + hc.viewDescription = description hc.mu.Unlock() hc.notifyListeners() } -// SetViewStat sets a view-specific stat -func (hc *HeaderConfig) SetViewStat(key, value string, priority int) { - hc.mu.Lock() - hc.viewStats[key] = StatValue{Value: value, Priority: priority} - hc.mu.Unlock() - hc.notifyListeners() -} - -// ClearViewStats clears all view-specific stats -func (hc *HeaderConfig) ClearViewStats() { - hc.mu.Lock() - hc.viewStats = make(map[string]StatValue) - hc.mu.Unlock() - hc.notifyListeners() -} - -// GetStats returns all stats (base + view) merged together -func (hc *HeaderConfig) GetStats() map[string]StatValue { +// GetViewName returns the current view name +func (hc *HeaderConfig) GetViewName() string { hc.mu.RLock() defer hc.mu.RUnlock() + return hc.viewName +} - result := make(map[string]StatValue) - maps.Copy(result, hc.baseStats) - maps.Copy(result, hc.viewStats) - return result +// GetViewDescription returns the current view description +func (hc *HeaderConfig) GetViewDescription() string { + hc.mu.RLock() + defer hc.mu.RUnlock() + return hc.viewDescription } // SetBurndown updates the burndown chart data diff --git a/model/header_config_test.go b/model/header_config_test.go index 2eb1fcc..833f0a9 100644 --- a/model/header_config_test.go +++ b/model/header_config_test.go @@ -35,8 +35,12 @@ func TestNewHeaderConfig(t *testing.T) { t.Error("initial GetPluginActions() should be empty") } - if len(hc.GetStats()) != 0 { - t.Error("initial GetStats() should be empty") + if hc.GetViewName() != "" { + t.Error("initial GetViewName() should be empty") + } + + if hc.GetViewDescription() != "" { + t.Error("initial GetViewDescription() should be empty") } if len(hc.GetBurndown()) != 0 { @@ -102,122 +106,42 @@ func TestHeaderConfig_PluginActions(t *testing.T) { } } -func TestHeaderConfig_BaseStats(t *testing.T) { +func TestHeaderConfig_ViewInfo(t *testing.T) { hc := NewHeaderConfig() - // Set base stats - hc.SetBaseStat("version", "v1.0.0", 100) - hc.SetBaseStat("user", "testuser", 90) + hc.SetViewInfo("Kanban", "Tasks moving through stages") - stats := hc.GetStats() - - if len(stats) != 2 { - t.Errorf("len(GetStats()) = %d, want 2", len(stats)) + if got := hc.GetViewName(); got != "Kanban" { + t.Errorf("GetViewName() = %q, want %q", got, "Kanban") } - if stats["version"].Value != "v1.0.0" { - t.Errorf("stats[version].Value = %q, want %q", stats["version"].Value, "v1.0.0") + if got := hc.GetViewDescription(); got != "Tasks moving through stages" { + t.Errorf("GetViewDescription() = %q, want %q", got, "Tasks moving through stages") } - if stats["version"].Priority != 100 { - t.Errorf("stats[version].Priority = %d, want 100", stats["version"].Priority) + // update overwrites + hc.SetViewInfo("Backlog", "Upcoming tasks") + + if got := hc.GetViewName(); got != "Backlog" { + t.Errorf("GetViewName() after update = %q, want %q", got, "Backlog") } - if stats["user"].Value != "testuser" { - t.Errorf("stats[user].Value = %q, want %q", stats["user"].Value, "testuser") + if got := hc.GetViewDescription(); got != "Upcoming tasks" { + t.Errorf("GetViewDescription() after update = %q, want %q", got, "Upcoming tasks") } } -func TestHeaderConfig_ViewStats(t *testing.T) { +func TestHeaderConfig_ViewInfoEmptyDescription(t *testing.T) { hc := NewHeaderConfig() - // Set view stats - hc.SetViewStat("total", "42", 50) - hc.SetViewStat("selected", "5", 60) + hc.SetViewInfo("Task Detail", "") - stats := hc.GetStats() - - if len(stats) != 2 { - t.Errorf("len(GetStats()) = %d, want 2", len(stats)) + if got := hc.GetViewName(); got != "Task Detail" { + t.Errorf("GetViewName() = %q, want %q", got, "Task Detail") } - if stats["total"].Value != "42" { - t.Errorf("stats[total].Value = %q, want %q", stats["total"].Value, "42") - } - - if stats["selected"].Value != "5" { - t.Errorf("stats[selected].Value = %q, want %q", stats["selected"].Value, "5") - } -} - -func TestHeaderConfig_StatsMerging(t *testing.T) { - hc := NewHeaderConfig() - - // Set base stats - hc.SetBaseStat("version", "v1.0.0", 100) - hc.SetBaseStat("user", "testuser", 90) - - // Set view stats (including one that overrides base) - hc.SetViewStat("total", "42", 50) - hc.SetViewStat("user", "viewuser", 95) // Override base stat - - stats := hc.GetStats() - - // Should have 3 unique keys (version, user, total) - if len(stats) != 3 { - t.Errorf("len(GetStats()) = %d, want 3", len(stats)) - } - - // View stat should override base stat - if stats["user"].Value != "viewuser" { - t.Errorf("stats[user].Value = %q, want %q (view should override base)", - stats["user"].Value, "viewuser") - } - - if stats["user"].Priority != 95 { - t.Errorf("stats[user].Priority = %d, want 95", stats["user"].Priority) - } - - // Base stats should still be present - if stats["version"].Value != "v1.0.0" { - t.Error("base stat 'version' missing after merge") - } - - // View stat should be present - if stats["total"].Value != "42" { - t.Error("view stat 'total' missing after merge") - } -} - -func TestHeaderConfig_ClearViewStats(t *testing.T) { - hc := NewHeaderConfig() - - // Set both base and view stats - hc.SetBaseStat("version", "v1.0.0", 100) - hc.SetViewStat("total", "42", 50) - hc.SetViewStat("selected", "5", 60) - - stats := hc.GetStats() - if len(stats) != 3 { - t.Errorf("len(GetStats()) before clear = %d, want 3", len(stats)) - } - - // Clear view stats - hc.ClearViewStats() - - stats = hc.GetStats() - - // Should only have base stats now - if len(stats) != 1 { - t.Errorf("len(GetStats()) after clear = %d, want 1", len(stats)) - } - - if stats["version"].Value != "v1.0.0" { - t.Error("base stats should remain after ClearViewStats") - } - - if _, ok := stats["total"]; ok { - t.Error("view stats should be cleared") + if got := hc.GetViewDescription(); got != "" { + t.Errorf("GetViewDescription() = %q, want empty", got) } } @@ -341,9 +265,7 @@ func TestHeaderConfig_ListenerNotification(t *testing.T) { }{ {"SetViewActions", func() { hc.SetViewActions([]HeaderAction{{ID: "test"}}) }}, {"SetPluginActions", func() { hc.SetPluginActions([]HeaderAction{{ID: "test"}}) }}, - {"SetBaseStat", func() { hc.SetBaseStat("key", "value", 1) }}, - {"SetViewStat", func() { hc.SetViewStat("key", "value", 1) }}, - {"ClearViewStats", func() { hc.ClearViewStats() }}, + {"SetViewInfo", func() { hc.SetViewInfo("Test", "desc") }}, {"SetBurndown", func() { hc.SetBurndown([]store.BurndownPoint{{Date: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)}}) }}, {"SetVisible", func() { hc.SetVisible(false); hc.SetVisible(true) }}, {"ToggleUserPreference", func() { hc.ToggleUserPreference() }}, @@ -423,7 +345,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { id2 := hc.AddListener(listener2) // Both should be notified - hc.SetBaseStat("test", "value", 1) + hc.SetViewInfo("Test", "desc") time.Sleep(10 * time.Millisecond) @@ -436,7 +358,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { // Remove one hc.RemoveListener(id1) - hc.SetViewStat("another", "test", 1) + hc.SetViewInfo("Test2", "desc2") time.Sleep(10 * time.Millisecond) @@ -449,7 +371,7 @@ func TestHeaderConfig_MultipleListeners(t *testing.T) { // Remove second hc.RemoveListener(id2) - hc.ClearViewStats() + hc.SetViewInfo("Test3", "desc3") time.Sleep(10 * time.Millisecond) @@ -474,14 +396,10 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) { done <- true }() - // Writer goroutine - stats + // Writer goroutine - view info go func() { for i := range 50 { - hc.SetBaseStat("key", "value", i) - hc.SetViewStat("viewkey", "viewvalue", i) - if i%10 == 0 { - hc.ClearViewStats() - } + hc.SetViewInfo("View"+string(rune('a'+i%26)), "desc") } done <- true }() @@ -502,7 +420,8 @@ func TestHeaderConfig_ConcurrentAccess(t *testing.T) { for range 100 { _ = hc.GetViewActions() _ = hc.GetPluginActions() - _ = hc.GetStats() + _ = hc.GetViewName() + _ = hc.GetViewDescription() _ = hc.GetBurndown() _ = hc.IsVisible() _ = hc.GetUserPreference() @@ -540,35 +459,6 @@ func TestHeaderConfig_EmptyCollections(t *testing.T) { } } -func TestHeaderConfig_StatPriorityOrdering(t *testing.T) { - hc := NewHeaderConfig() - - // Set stats with different priorities - hc.SetBaseStat("low", "value", 10) - hc.SetBaseStat("high", "value", 100) - hc.SetBaseStat("medium", "value", 50) - - stats := hc.GetStats() - - // Verify all stats are present (priority doesn't filter, just orders) - if len(stats) != 3 { - t.Errorf("len(stats) = %d, want 3", len(stats)) - } - - // Verify priorities are preserved - if stats["low"].Priority != 10 { - t.Errorf("stats[low].Priority = %d, want 10", stats["low"].Priority) - } - - if stats["high"].Priority != 100 { - t.Errorf("stats[high].Priority = %d, want 100", stats["high"].Priority) - } - - if stats["medium"].Priority != 50 { - t.Errorf("stats[medium].Priority = %d, want 50", stats["medium"].Priority) - } -} - func TestHeaderConfig_ListenerIDUniqueness(t *testing.T) { hc := NewHeaderConfig() diff --git a/model/view_id.go b/model/view_id.go index 3e6b5b0..f53ed09 100644 --- a/model/view_id.go +++ b/model/view_id.go @@ -14,6 +14,18 @@ const ( PluginViewIDPrefix ViewID = "plugin:" // Prefix for plugin views ) +// built-in view names and descriptions for the header info section +const ( + TaskDetailViewName = "Tiki Detail" + TaskDetailViewDesc = "tiki overview. Quick edit, edit dependencies, tags or edit source file" + + TaskEditViewName = "Task Edit" + TaskEditViewDesc = "Cycle through fields to edit title, status, priority and other" + + DepsEditorViewName = "Dependencies" + DepsEditorViewDesc = "Move a tiki to Blocks to make it block edited tiki. Move it to Depends to make edited tiki depend on it" +) + // IsPluginViewID checks if a ViewID is for a plugin view func IsPluginViewID(id ViewID) bool { return strings.HasPrefix(string(id), string(PluginViewIDPrefix)) diff --git a/plugin/definition.go b/plugin/definition.go index c59f3aa..a57a504 100644 --- a/plugin/definition.go +++ b/plugin/definition.go @@ -9,6 +9,7 @@ import ( // Plugin interface defines the common methods for all plugins type Plugin interface { GetName() string + GetDescription() string GetActivationKey() (tcell.Key, rune, tcell.ModMask) GetFilePath() string GetConfigIndex() int @@ -19,6 +20,7 @@ type Plugin interface { // BasePlugin holds the common fields for all plugins type BasePlugin struct { Name string // display name shown in caption + Description string // short description shown in header info section Key tcell.Key // tcell key constant (e.g. KeyCtrlH) Rune rune // printable character (e.g. 'L') Modifier tcell.ModMask // modifier keys (Alt, Shift, Ctrl, etc.) @@ -34,6 +36,10 @@ func (p *BasePlugin) GetName() string { return p.Name } +func (p *BasePlugin) GetDescription() string { + return p.Description +} + func (p *BasePlugin) GetActivationKey() (tcell.Key, rune, tcell.ModMask) { return p.Key, p.Rune, p.Modifier } diff --git a/plugin/merger.go b/plugin/merger.go index 027437f..47c677e 100644 --- a/plugin/merger.go +++ b/plugin/merger.go @@ -6,20 +6,21 @@ import ( // pluginFileConfig represents the YAML structure of a plugin file type pluginFileConfig struct { - Name string `yaml:"name"` - Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color - Background string `yaml:"background"` - Key string `yaml:"key"` // single character - Filter string `yaml:"filter"` - Sort string `yaml:"sort"` - View string `yaml:"view"` // "compact" or "expanded" (default: compact) - Type string `yaml:"type"` // "tiki" or "doki" (default: tiki) - Fetcher string `yaml:"fetcher"` - Text string `yaml:"text"` - URL string `yaml:"url"` - Lanes []PluginLaneConfig `yaml:"lanes"` - Actions []PluginActionConfig `yaml:"actions"` - Default bool `yaml:"default"` + Name string `yaml:"name"` + Description string `yaml:"description"` // short description shown in header info + Foreground string `yaml:"foreground"` // hex color like "#ff0000" or named color + Background string `yaml:"background"` + Key string `yaml:"key"` // single character + Filter string `yaml:"filter"` + Sort string `yaml:"sort"` + View string `yaml:"view"` // "compact" or "expanded" (default: compact) + Type string `yaml:"type"` // "tiki" or "doki" (default: tiki) + Fetcher string `yaml:"fetcher"` + Text string `yaml:"text"` + URL string `yaml:"url"` + Lanes []PluginLaneConfig `yaml:"lanes"` + Actions []PluginActionConfig `yaml:"actions"` + Default bool `yaml:"default"` } // mergePluginDefinitions merges a base plugin with a configured override. @@ -33,6 +34,7 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin { result := &TikiPlugin{ BasePlugin: BasePlugin{ Name: baseTiki.Name, + Description: baseTiki.Description, Key: baseTiki.Key, Rune: baseTiki.Rune, Modifier: baseTiki.Modifier, // FIXED: Copy modifier from base @@ -50,6 +52,9 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin { } // Apply overrides for non-zero values + if overrideTiki.Description != "" { + result.Description = overrideTiki.Description + } if overrideTiki.Key != 0 || overrideTiki.Rune != 0 || overrideTiki.Modifier != 0 { result.Key = overrideTiki.Key result.Rune = overrideTiki.Rune diff --git a/plugin/parser.go b/plugin/parser.go index 69b64ec..5b3ed79 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -35,6 +35,7 @@ func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) { base := BasePlugin{ Name: cfg.Name, + Description: cfg.Description, Key: key, Rune: r, Modifier: mod, diff --git a/view/doki_plugin_view.go b/view/doki_plugin_view.go index 4b0e4ae..2ce4a5c 100644 --- a/view/doki_plugin_view.go +++ b/view/doki_plugin_view.go @@ -139,6 +139,12 @@ func (dv *DokiView) rebuildLayout() { // ShowNavigation returns true — doki views always show plugin navigation keys. func (dv *DokiView) ShowNavigation() bool { return true } +// GetViewName returns the plugin name for the header info section +func (dv *DokiView) GetViewName() string { return dv.pluginDef.GetName() } + +// GetViewDescription returns the plugin description for the header info section +func (dv *DokiView) GetViewDescription() string { return dv.pluginDef.GetDescription() } + func (dv *DokiView) GetPrimitive() tview.Primitive { return dv.root } diff --git a/view/header/header.go b/view/header/header.go index 197b1d9..3b50724 100644 --- a/view/header/header.go +++ b/view/header/header.go @@ -1,8 +1,6 @@ package header import ( - "sort" - "github.com/boolean-maybe/tiki/config" "github.com/boolean-maybe/tiki/model" @@ -42,12 +40,12 @@ const ( HeaderColumnSpacing = 2 // spaces between action columns in ContextHelp ) -// HeaderWidget displays stats, available actions and burndown chart +// HeaderWidget displays view info, available actions and burndown chart type HeaderWidget struct { *tview.Flex // Components - stats *StatsWidget + info *InfoWidget contextHelp *ContextHelpWidget chart *ChartWidget @@ -68,7 +66,7 @@ type HeaderWidget struct { // NewHeaderWidget creates a header widget that observes HeaderConfig for all state func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget { - stats := NewStatsWidget() + info := NewInfoWidget() contextHelp := NewContextHelpWidget() chart := NewChartWidgetSimple() // No store dependency, data comes from HeaderConfig @@ -81,7 +79,7 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget { hw := &HeaderWidget{ Flex: flex, - stats: stats, + info: info, leftSpacer: tview.NewBox(), contextHelp: contextHelp, gap: tview.NewBox(), @@ -101,9 +99,8 @@ func NewHeaderWidget(headerConfig *model.HeaderConfig) *HeaderWidget { // rebuild reads all data from HeaderConfig and updates display func (h *HeaderWidget) rebuild() { - // Update stats from HeaderConfig - stats := h.headerConfig.GetStats() - h.rebuildStats(stats) + // Update view info from HeaderConfig + h.info.SetViewInfo(h.headerConfig.GetViewName(), h.headerConfig.GetViewDescription()) // Update burndown chart from HeaderConfig burndown := h.headerConfig.GetBurndown() @@ -119,35 +116,6 @@ func (h *HeaderWidget) rebuild() { } } -// rebuildStats updates the stats widget from HeaderConfig stats -func (h *HeaderWidget) rebuildStats(stats map[string]model.StatValue) { - // Remove stats that are no longer in the config - for _, key := range h.stats.GetKeys() { - if _, exists := stats[key]; !exists { - h.stats.RemoveStat(key) - } - } - - // Sort stats by priority for consistent ordering - type statEntry struct { - key string - value string - priority int - } - entries := make([]statEntry, 0, len(stats)) - for k, v := range stats { - entries = append(entries, statEntry{key: k, value: v.Value, priority: v.Priority}) - } - sort.Slice(entries, func(i, j int) bool { - return entries[i].priority < entries[j].priority - }) - - // Update stats widget - for _, e := range entries { - h.stats.AddStat(e.key, e.value, e.priority) - } -} - // Draw overrides to implement responsive layout func (h *HeaderWidget) Draw(screen tcell.Screen) { _, _, width, _ := h.GetRect() @@ -172,7 +140,7 @@ func (h *HeaderWidget) rebuildLayout(width int) { // and to physically remove the chart when hidden. h.Clear() h.SetDirection(tview.FlexColumn) - h.AddItem(h.stats.Primitive(), StatsWidth, 0, false) + h.AddItem(h.info.Primitive(), InfoWidth, 0, false) h.AddItem(h.leftSpacer, 0, 1, false) h.AddItem(h.contextHelp.Primitive(), layout.ContextWidth, 0, false) if layout.ChartVisible { diff --git a/view/header/header_layout.go b/view/header/header_layout.go index 1f092c4..ce595e8 100644 --- a/view/header/header_layout.go +++ b/view/header/header_layout.go @@ -3,7 +3,7 @@ package header // layout constants for the header widget. kept here alongside the pure layout // function so the algorithm and its inputs are co-located. const ( - StatsWidth = 30 // fixed width for stats section + InfoWidth = 40 // fixed width for info section (view name + description) ChartWidth = 14 // fixed width for burndown chart LogoWidth = 25 // fixed width for logo MinContextWidth = 40 // minimum width for context help to remain readable @@ -20,12 +20,12 @@ type HeaderLayout struct { // CalculateHeaderLayout computes header component widths from two integer inputs. // // Rules: -// 1. availableBetween = totalWidth - StatsWidth - LogoWidth (clamped to 0) +// 1. availableBetween = totalWidth - InfoWidth - LogoWidth (clamped to 0) // 2. requiredContext = max(contextHelpWidth, MinContextWidth) when contextHelpWidth > 0 // 3. chart is visible when availableBetween >= requiredContext + ChartSpacing + ChartWidth // 4. contextWidth is clamped to availableBetween minus chart reservation when chart visible func CalculateHeaderLayout(totalWidth, contextHelpWidth int) HeaderLayout { - availableBetween := max(totalWidth-StatsWidth-LogoWidth, 0) + availableBetween := max(totalWidth-InfoWidth-LogoWidth, 0) requiredContext := contextHelpWidth if requiredContext < MinContextWidth && requiredContext > 0 { diff --git a/view/header/header_layout_test.go b/view/header/header_layout_test.go index 6f2c86c..777acf1 100644 --- a/view/header/header_layout_test.go +++ b/view/header/header_layout_test.go @@ -9,42 +9,42 @@ import ( // --- pure layout function tests --- func TestCalculateHeaderLayout_chartVisibleAtThreshold(t *testing.T) { - // availableBetween = 119 - 30 - 25 = 64 + // availableBetween = 129 - 40 - 25 = 64 // requiredContext = max(10, 40) = 40 // required for chart = 40 + 10 + 14 = 64 → exactly fits - layout := CalculateHeaderLayout(119, 10) + layout := CalculateHeaderLayout(129, 10) if !layout.ChartVisible { - t.Fatal("expected chart visible at width=119, contextHelp=10") + t.Fatal("expected chart visible at width=129, contextHelp=10") } } func TestCalculateHeaderLayout_chartHiddenJustBelow(t *testing.T) { - // availableBetween = 118 - 30 - 25 = 63 < 64 - layout := CalculateHeaderLayout(118, 10) + // availableBetween = 128 - 40 - 25 = 63 < 64 + layout := CalculateHeaderLayout(128, 10) if layout.ChartVisible { - t.Fatal("expected chart hidden at width=118, contextHelp=10") + t.Fatal("expected chart hidden at width=128, contextHelp=10") } } func TestCalculateHeaderLayout_chartThresholdGrowsWithContextHelp(t *testing.T) { // contextHelpWidth=60 already >= MinContextWidth so requiredContext=60 // required = 60 + 10 + 14 = 84; availableBetween must be >= 84 - // totalWidth = 84 + 30 + 25 = 139 - layout := CalculateHeaderLayout(139, 60) + // totalWidth = 84 + 40 + 25 = 149 + layout := CalculateHeaderLayout(149, 60) if !layout.ChartVisible { - t.Fatal("expected chart visible at width=139, contextHelp=60") + t.Fatal("expected chart visible at width=149, contextHelp=60") } - layout = CalculateHeaderLayout(138, 60) + layout = CalculateHeaderLayout(148, 60) if layout.ChartVisible { - t.Fatal("expected chart hidden at width=138, contextHelp=60") + t.Fatal("expected chart hidden at width=148, contextHelp=60") } } func TestCalculateHeaderLayout_contextWidthWithChart(t *testing.T) { // width=200, contextHelp=50 - // availableBetween = 200 - 30 - 25 = 145 - // requiredContext = 50, chart required = 50+10+14 = 74 <= 145 → chart visible - // maxContextWidth = 145 - (10+14) = 121; contextWidth = min(50, 121) = 50 + // availableBetween = 200 - 40 - 25 = 135 + // requiredContext = 50, chart required = 50+10+14 = 74 <= 135 → chart visible + // maxContextWidth = 135 - (10+14) = 111; contextWidth = min(50, 111) = 50 layout := CalculateHeaderLayout(200, 50) if !layout.ChartVisible { t.Fatal("expected chart visible") @@ -56,15 +56,15 @@ func TestCalculateHeaderLayout_contextWidthWithChart(t *testing.T) { func TestCalculateHeaderLayout_contextWidthClampedByAvailable(t *testing.T) { // width=100, contextHelp=200 (too wide) - // availableBetween = 100 - 30 - 25 = 45 - // requiredContext = 200; chart required = 214 > 45 → chart hidden - // maxContextWidth = 45; contextWidth clamped to 45 + // availableBetween = 100 - 40 - 25 = 35 + // requiredContext = 200; chart required = 214 > 35 → chart hidden + // maxContextWidth = 35; contextWidth clamped to 35 layout := CalculateHeaderLayout(100, 200) if layout.ChartVisible { t.Fatal("expected chart hidden when context too wide") } - if layout.ContextWidth != 45 { - t.Errorf("contextWidth = %d, want 45", layout.ContextWidth) + if layout.ContextWidth != 35 { + t.Errorf("contextWidth = %d, want 35", layout.ContextWidth) } } @@ -80,8 +80,8 @@ func TestCalculateHeaderLayout_contextWidthFlooredAtMinContextWidth(t *testing.T func TestCalculateHeaderLayout_zeroContextHelp(t *testing.T) { // contextHelpWidth=0: requiredContext stays 0 (guard: > 0 check) // required for chart = 0 + 10 + 14 = 24 - // availableBetween at width=119 = 64 >= 24 → chart visible - layout := CalculateHeaderLayout(119, 0) + // availableBetween at width=129 = 64 >= 24 → chart visible + layout := CalculateHeaderLayout(129, 0) if !layout.ChartVisible { t.Fatal("expected chart visible with zero-width context help") } @@ -98,7 +98,7 @@ func TestCalculateHeaderLayout_negativeContextHelp(t *testing.T) { } func TestCalculateHeaderLayout_veryNarrowTerminal(t *testing.T) { - // width < StatsWidth + LogoWidth → availableBetween clamped to 0 + // width < InfoWidth + LogoWidth → availableBetween clamped to 0 // chart cannot be visible; contextWidth = 0 layout := CalculateHeaderLayout(40, 30) if layout.ChartVisible { @@ -109,11 +109,11 @@ func TestCalculateHeaderLayout_veryNarrowTerminal(t *testing.T) { } } -func TestCalculateHeaderLayout_exactlyStatsAndLogo(t *testing.T) { - // width = StatsWidth + LogoWidth = 55 → availableBetween = 0 - layout := CalculateHeaderLayout(StatsWidth+LogoWidth, 10) +func TestCalculateHeaderLayout_exactlyInfoAndLogo(t *testing.T) { + // width = InfoWidth + LogoWidth = 65 → availableBetween = 0 + layout := CalculateHeaderLayout(InfoWidth+LogoWidth, 10) if layout.ChartVisible { - t.Fatal("expected chart hidden when no space between stats and logo") + t.Fatal("expected chart hidden when no space between info and logo") } if layout.ContextWidth != 0 { t.Errorf("contextWidth = %d, want 0", layout.ContextWidth) @@ -122,10 +122,10 @@ func TestCalculateHeaderLayout_exactlyStatsAndLogo(t *testing.T) { func TestCalculateHeaderLayout_chartHiddenContextFillsAvailable(t *testing.T) { // chart hidden; contextWidth should use full availableBetween - // width=118, contextHelp=63 - // availableBetween = 63; chart requires 40+24=64 > 63 → hidden + // width=128, contextHelp=63 + // availableBetween = 128 - 40 - 25 = 63; chart requires 63+24=87 > 63 → hidden // maxContextWidth = 63; contextWidth = min(63, 63) = 63 - layout := CalculateHeaderLayout(118, 63) + layout := CalculateHeaderLayout(128, 63) if layout.ChartVisible { t.Fatal("expected chart hidden") } @@ -142,13 +142,13 @@ func TestHeaderWidget_chartVisibilityThreshold_default(t *testing.T) { defer h.Cleanup() h.contextHelp.width = 10 - h.rebuildLayout(119) + h.rebuildLayout(129) if !h.chartVisible { - t.Fatalf("expected chart visible at width=119") + t.Fatalf("expected chart visible at width=129") } - h.rebuildLayout(118) + h.rebuildLayout(128) if h.chartVisible { - t.Fatalf("expected chart hidden at width=118") + t.Fatalf("expected chart hidden at width=128") } } @@ -159,13 +159,13 @@ func TestHeaderWidget_chartVisibilityThreshold_growsWithContextHelp(t *testing.T h.contextHelp.width = 60 - h.rebuildLayout(138) + h.rebuildLayout(148) if h.chartVisible { - t.Fatalf("expected chart hidden at width=138 for context=60") + t.Fatalf("expected chart hidden at width=148 for context=60") } - h.rebuildLayout(139) + h.rebuildLayout(149) if !h.chartVisible { - t.Fatalf("expected chart visible at width=139 for context=60") + t.Fatalf("expected chart visible at width=149 for context=60") } } diff --git a/view/header/info.go b/view/header/info.go new file mode 100644 index 0000000..193fd82 --- /dev/null +++ b/view/header/info.go @@ -0,0 +1,56 @@ +package header + +import ( + "fmt" + "strings" + + "github.com/boolean-maybe/tiki/config" + + "github.com/rivo/tview" +) + +// InfoWidget displays the current view name and description in the header +type InfoWidget struct { + *tview.TextView +} + +// NewInfoWidget creates a new info display widget +func NewInfoWidget() *InfoWidget { + tv := tview.NewTextView() + tv.SetDynamicColors(true) + tv.SetTextAlign(tview.AlignLeft) + tv.SetWrap(true) + tv.SetWordWrap(true) + tv.SetBorderPadding(0, 0, 1, 0) + + return &InfoWidget{TextView: tv} +} + +// SetViewInfo updates the displayed view name and description +func (iw *InfoWidget) SetViewInfo(name, description string) { + colors := config.GetColors() + + // convert "[orange]" to "[orange::b]" for bold name + boldColor := makeBold(colors.HeaderInfoLabel) + + separator := strings.Repeat("─", InfoWidth) + + var text string + if description != "" { + text = fmt.Sprintf("%s%s[-::-]\n%s%s[-]\n%s%s", boldColor, name, colors.HeaderInfoSeparator, separator, colors.HeaderInfoDesc, description) + } else { + text = fmt.Sprintf("%s%s[-::-]", boldColor, name) + } + + iw.SetText(text) +} + +// makeBold converts a tview color tag like "[orange]" to "[orange::b]" +func makeBold(colorTag string) string { + return strings.TrimSuffix(colorTag, "]") + "::b]" +} + +// Primitive returns the underlying tview primitive +func (iw *InfoWidget) Primitive() tview.Primitive { + return iw.TextView +} diff --git a/view/header/stats.go b/view/header/stats.go deleted file mode 100644 index 828970e..0000000 --- a/view/header/stats.go +++ /dev/null @@ -1,159 +0,0 @@ -package header - -import ( - "fmt" - "sort" - "strings" - "sync" - - "github.com/boolean-maybe/tiki/config" - - "github.com/rivo/tview" -) - -// StatCollector allows components to register and manage dynamic stats -// displayed in the header's stats widget. -type StatCollector interface { - // AddStat registers or updates a stat. Lower priority values display higher. - // Returns false if the stat limit (6) is reached and key doesn't exist. - AddStat(key, value string, priority int) bool - - // RemoveStat removes a stat by key. Returns true if stat existed. - RemoveStat(key string) bool -} - -// statEntry represents a single stat in the widget -type statEntry struct { - key string - value string - priority int -} - -// StatsWidget displays application statistics dynamically -type StatsWidget struct { - *tview.TextView - - stats map[string]*statEntry // key -> entry for O(1) lookup - sorted []*statEntry // sorted by priority for rendering - mu sync.RWMutex // thread safety - maxStats int // fixed at 6 -} - -// NewStatsWidget creates a new stats display widget -func NewStatsWidget() *StatsWidget { - tv := tview.NewTextView() - tv.SetDynamicColors(true) - tv.SetTextAlign(tview.AlignLeft) - tv.SetWrap(false) - - sw := &StatsWidget{ - TextView: tv, - stats: make(map[string]*statEntry), - sorted: make([]*statEntry, 0, 6), - maxStats: 6, - } - - return sw -} - -// AddStat registers or updates a stat. Lower priority values display higher. -// Returns false if the stat limit (6) is reached and key doesn't exist. -func (sw *StatsWidget) AddStat(key, value string, priority int) bool { - sw.mu.Lock() - defer sw.mu.Unlock() - - // Check if key exists (update case) - if entry, exists := sw.stats[key]; exists { - entry.value = value - entry.priority = priority - sw.rebuildSorted() - sw.update() - return true - } - - // Check limit for new key - if len(sw.stats) >= sw.maxStats { - return false - } - - // Add new entry - entry := &statEntry{ - key: key, - value: value, - priority: priority, - } - sw.stats[key] = entry - sw.rebuildSorted() - sw.update() - return true -} - -// RemoveStat removes a stat by key. Returns true if stat existed. -func (sw *StatsWidget) RemoveStat(key string) bool { - sw.mu.Lock() - defer sw.mu.Unlock() - - if _, exists := sw.stats[key]; !exists { - return false - } - - delete(sw.stats, key) - sw.rebuildSorted() - sw.update() - return true -} - -// GetKeys returns all current stat keys -func (sw *StatsWidget) GetKeys() []string { - sw.mu.RLock() - defer sw.mu.RUnlock() - - keys := make([]string, 0, len(sw.stats)) - for k := range sw.stats { - keys = append(keys, k) - } - return keys -} - -// Primitive returns the underlying tview primitive -func (sw *StatsWidget) Primitive() tview.Primitive { - return sw.TextView -} - -// rebuildSorted rebuilds the sorted slice from the map (must be called with lock held) -func (sw *StatsWidget) rebuildSorted() { - sw.sorted = make([]*statEntry, 0, len(sw.stats)) - for _, entry := range sw.stats { - sw.sorted = append(sw.sorted, entry) - } - sort.Slice(sw.sorted, func(i, j int) bool { - return sw.sorted[i].priority < sw.sorted[j].priority - }) -} - -// update refreshes the stats display (must be called with lock held) -func (sw *StatsWidget) update() { - if len(sw.sorted) == 0 { - sw.SetText("") - return - } - - // find max label length for value alignment - maxLabelLen := 0 - for _, entry := range sw.sorted { - if len(entry.key) > maxLabelLen { - maxLabelLen = len(entry.key) - } - } - - colors := config.GetColors() - - var lines []string - for _, entry := range sw.sorted { - // pad after colon to align values - padding := strings.Repeat(" ", maxLabelLen-len(entry.key)) - lines = append(lines, fmt.Sprintf("%s%s:%s%s %s", colors.HeaderInfoLabel, entry.key, colors.HeaderInfoValue, padding, entry.value)) - } - - sw.SetText(strings.Join(lines, "\n")) -} diff --git a/view/root_layout.go b/view/root_layout.go index fd8cc01..31578fe 100644 --- a/view/root_layout.go +++ b/view/root_layout.go @@ -149,8 +149,12 @@ func (rl *RootLayout) onLayoutChange() { rl.headerConfig.SetPluginActions(nil) } - // Apply view-specific stats from the view - rl.updateViewStats(newView) + // Update header info section with view name and description + if vip, ok := newView.(controller.ViewInfoProvider); ok { + rl.headerConfig.SetViewInfo(vip.GetViewName(), vip.GetViewDescription()) + } + + // Update statusline stats from the view rl.updateStatuslineViewStats(newView) // Run view activated callback (for focus setters, etc.) @@ -301,21 +305,10 @@ func (rl *RootLayout) Cleanup() { // onStoreChange is called when the task store changes (task created/updated/deleted) func (rl *RootLayout) onStoreChange() { if rl.contentView != nil { - rl.updateViewStats(rl.contentView) rl.updateStatuslineViewStats(rl.contentView) } } -// updateViewStats reads stats from the view and updates the header -func (rl *RootLayout) updateViewStats(v controller.View) { - rl.headerConfig.ClearViewStats() - if sp, ok := v.(controller.StatsProvider); ok { - for _, stat := range sp.GetStats() { - rl.headerConfig.SetViewStat(stat.Name, stat.Value, stat.Order) - } - } -} - // updateStatuslineViewStats reads stats from the view and updates the statusline right section. // Reuses StatsProvider — no separate interface needed until header and statusline stats diverge. func (rl *RootLayout) updateStatuslineViewStats(v controller.View) { diff --git a/view/statusline/statusline.go b/view/statusline/statusline.go index 50e0703..7c5cc4c 100644 --- a/view/statusline/statusline.go +++ b/view/statusline/statusline.go @@ -97,12 +97,12 @@ func (sw *StatuslineWidget) render(width int) { msgRendered := sw.renderMessage(msg, colors) msgLen := visibleLen(msg) - // pad to fill the width + // pad to fill the width with the fill background color padLen := width - leftLen - msgLen - rightStatsLen if padLen < 1 { padLen = 1 } - padding := strings.Repeat(" ", padLen) + padding := fmt.Sprintf("[-:%s]%s[-:-]", colors.StatuslineFillBg, strings.Repeat(" ", padLen)) sw.SetText(left + padding + msgRendered + rightStats) } @@ -123,8 +123,8 @@ func (sw *StatuslineWidget) renderLeftSegments(segments []statSegment, colors *c // segment text: " value " fmt.Fprintf(&b, "[%s:%s] %s ", fg, bg, seg.value) - // separator: fg = current bg (creates the arrow), bg = next segment's bg or default - nextBg := "-" // terminal default for last segment + // separator: fg = current bg (creates the arrow), bg = next segment's bg or fill + nextBg := colors.StatuslineFillBg if i < len(segments)-1 { nextBg, _ = segmentColors(i+1, colors) } @@ -148,8 +148,8 @@ func (sw *StatuslineWidget) renderRightSegments(segments []statSegment, colors * for i, seg := range segments { bg, fg := segmentColors(i, colors) - // separator before segment: fg = segment bg, bg = previous segment bg (or terminal default) - prevBg := "-" // terminal default for first right segment + // separator before segment: fg = segment bg, bg = previous segment bg (or fill) + prevBg := colors.StatuslineFillBg if i > 0 { prevBg, _ = segmentColors(i-1, colors) } diff --git a/view/statusline/statusline_test.go b/view/statusline/statusline_test.go index 490a2d0..ccfece2 100644 --- a/view/statusline/statusline_test.go +++ b/view/statusline/statusline_test.go @@ -17,6 +17,7 @@ func testColors() *config.ColorConfig { StatuslineAccentFg: "#accent_fg", StatuslineMessageFg: "#msg_fg", StatuslineMessageBg: "#msg_bg", + StatuslineFillBg: "#fill_bg", } } @@ -108,9 +109,9 @@ func TestRenderLeftSegments_singleSegment(t *testing.T) { if !strings.Contains(result, "[#accent_fg:#accent_bg] v1.0 ") { t.Errorf("first segment should use accent colors, got %q", result) } - // separator: fg=accent_bg (current), bg="-" (last segment) - if !strings.Contains(result, "[#accent_bg:-]"+separatorRight) { - t.Errorf("separator should transition to terminal default, got %q", result) + // separator: fg=accent_bg (current), bg=fill (last segment) + if !strings.Contains(result, "[#accent_bg:#fill_bg]"+separatorRight) { + t.Errorf("separator should transition to fill background, got %q", result) } // ends with color reset if !strings.HasSuffix(result, "[-:-]") { @@ -161,9 +162,9 @@ func TestRenderRightSegments_singleSegment(t *testing.T) { if !strings.Contains(result, "[#accent_fg:#accent_bg] 42 ") { t.Errorf("segment 0 should use accent colors, got %q", result) } - // separator: fg=accent_bg, bg="-" (terminal default, first segment) - if !strings.Contains(result, "[#accent_bg:-]"+separatorLeft) { - t.Errorf("separator should be accent→terminal, got %q", result) + // separator: fg=accent_bg, bg=fill (first right segment) + if !strings.Contains(result, "[#accent_bg:#fill_bg]"+separatorLeft) { + t.Errorf("separator should be accent→fill, got %q", result) } } diff --git a/view/taskdetail/task_detail_view.go b/view/taskdetail/task_detail_view.go index b754fc6..9026053 100644 --- a/view/taskdetail/task_detail_view.go +++ b/view/taskdetail/task_detail_view.go @@ -62,6 +62,12 @@ func (tv *TaskDetailView) GetViewID() model.ViewID { return tv.viewID } +// GetViewName returns the view name for the header info section +func (tv *TaskDetailView) GetViewName() string { return model.TaskDetailViewName } + +// GetViewDescription returns the view description for the header info section +func (tv *TaskDetailView) GetViewDescription() string { return model.TaskDetailViewDesc } + // OnFocus is called when the view becomes active func (tv *TaskDetailView) OnFocus() { // Register listener for live updates diff --git a/view/taskdetail/task_edit_view.go b/view/taskdetail/task_edit_view.go index 18af2db..cded940 100644 --- a/view/taskdetail/task_edit_view.go +++ b/view/taskdetail/task_edit_view.go @@ -131,6 +131,12 @@ func (ev *TaskEditView) GetViewID() model.ViewID { return ev.viewID } +// GetViewName returns the view name for the header info section +func (ev *TaskEditView) GetViewName() string { return model.TaskEditViewName } + +// GetViewDescription returns the view description for the header info section +func (ev *TaskEditView) GetViewDescription() string { return model.TaskEditViewDesc } + // SetDescOnly enables description-only edit mode where metadata is read-only. func (ev *TaskEditView) SetDescOnly(descOnly bool) { ev.descOnly = descOnly diff --git a/view/tiki_plugin_view.go b/view/tiki_plugin_view.go index 28ec74c..59fa20f 100644 --- a/view/tiki_plugin_view.go +++ b/view/tiki_plugin_view.go @@ -195,6 +195,12 @@ func (pv *PluginView) GetActionRegistry() *controller.ActionRegistry { // ShowNavigation returns whether plugin navigation keys should be shown in the header. func (pv *PluginView) ShowNavigation() bool { return pv.showNavigation } +// GetViewName returns the plugin name for the header info section +func (pv *PluginView) GetViewName() string { return pv.pluginDef.GetName() } + +// GetViewDescription returns the plugin description for the header info section +func (pv *PluginView) GetViewDescription() string { return pv.pluginDef.GetDescription() } + // GetViewID returns the view identifier func (pv *PluginView) GetViewID() model.ViewID { return model.MakePluginViewID(pv.pluginDef.Name)