mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
replace header stats with view info
This commit is contained in:
parent
a0faa499ef
commit
5947292e0c
26 changed files with 272 additions and 476 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
56
view/header/info.go
Normal file
56
view/header/info.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"))
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue