mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
480 lines
12 KiB
Go
480 lines
12 KiB
Go
package model
|
|
|
|
import (
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/boolean-maybe/tiki/config"
|
|
"github.com/boolean-maybe/tiki/task"
|
|
)
|
|
|
|
// ViewMode represents the display mode for task boxes
|
|
type ViewMode string
|
|
|
|
const (
|
|
ViewModeCompact ViewMode = "compact" // 3-line display (5 total height with border)
|
|
ViewModeExpanded ViewMode = "expanded" // 7-line display (9 total height with border)
|
|
)
|
|
|
|
// PluginSelectionListener is called when plugin selection changes
|
|
type PluginSelectionListener func()
|
|
|
|
// PluginConfig holds selection state for a plugin view
|
|
type PluginConfig struct {
|
|
mu sync.RWMutex
|
|
pluginName string
|
|
selectedLane int
|
|
selectedIndices []int
|
|
laneColumns []int
|
|
laneWidths []int // per-lane width proportion (0 = equal share)
|
|
scrollOffsets []int // per-lane viewport position (top visible row)
|
|
preSearchLane int
|
|
preSearchIndices []int
|
|
viewMode ViewMode // compact or expanded display
|
|
configIndex int // index in workflow.yaml views array (-1 if not from a config file)
|
|
listeners map[int]PluginSelectionListener
|
|
nextListenerID int
|
|
searchState SearchState // search state (embedded)
|
|
}
|
|
|
|
// NewPluginConfig creates a plugin config
|
|
func NewPluginConfig(name string) *PluginConfig {
|
|
pc := &PluginConfig{
|
|
pluginName: name,
|
|
viewMode: ViewModeCompact,
|
|
configIndex: -1, // Default to -1 (not in config)
|
|
listeners: make(map[int]PluginSelectionListener),
|
|
nextListenerID: 1, // Start at 1 to avoid conflict with zero-value sentinel
|
|
}
|
|
pc.SetLaneLayout([]int{4}, nil)
|
|
return pc
|
|
}
|
|
|
|
// SetConfigIndex sets the config index for this plugin
|
|
func (pc *PluginConfig) SetConfigIndex(index int) {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
pc.configIndex = index
|
|
}
|
|
|
|
// GetPluginName returns the plugin name
|
|
func (pc *PluginConfig) GetPluginName() string {
|
|
return pc.pluginName
|
|
}
|
|
|
|
// SetLaneLayout configures lane columns and widths, and resets selection state as needed.
|
|
// Pass nil for widths to use equal proportions for all lanes.
|
|
func (pc *PluginConfig) SetLaneLayout(columns []int, widths []int) {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
|
|
pc.laneColumns = normalizeLaneColumns(columns)
|
|
pc.laneWidths = normalizeLaneWidths(widths, len(pc.laneColumns))
|
|
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.laneColumns))
|
|
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.laneColumns))
|
|
pc.scrollOffsets = ensureSelectionLength(pc.scrollOffsets, len(pc.laneColumns))
|
|
|
|
if pc.selectedLane < 0 || pc.selectedLane >= len(pc.laneColumns) {
|
|
pc.selectedLane = 0
|
|
}
|
|
}
|
|
|
|
// GetSelectedLane returns the selected lane index.
|
|
func (pc *PluginConfig) GetSelectedLane() int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
return pc.selectedLane
|
|
}
|
|
|
|
// SetSelectedLane sets the selected lane index.
|
|
func (pc *PluginConfig) SetSelectedLane(lane int) {
|
|
pc.mu.Lock()
|
|
if lane < 0 || lane >= len(pc.laneColumns) {
|
|
pc.mu.Unlock()
|
|
return
|
|
}
|
|
changed := pc.selectedLane != lane
|
|
pc.selectedLane = lane
|
|
pc.mu.Unlock()
|
|
if changed {
|
|
pc.notifyListeners()
|
|
}
|
|
}
|
|
|
|
// GetSelectedIndex returns the selected task index for the current lane.
|
|
func (pc *PluginConfig) GetSelectedIndex() int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
return pc.indexForLane(pc.selectedLane)
|
|
}
|
|
|
|
// GetSelectedIndexForLane returns the selected index for a lane.
|
|
func (pc *PluginConfig) GetSelectedIndexForLane(lane int) int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
return pc.indexForLane(lane)
|
|
}
|
|
|
|
// SetSelectedIndex sets the selected task index for the current lane.
|
|
func (pc *PluginConfig) SetSelectedIndex(idx int) {
|
|
pc.mu.Lock()
|
|
pc.setIndexForLane(pc.selectedLane, idx)
|
|
pc.mu.Unlock()
|
|
pc.notifyListeners()
|
|
}
|
|
|
|
// SetSelectedIndexForLane sets the selected index for a specific lane.
|
|
func (pc *PluginConfig) SetSelectedIndexForLane(lane int, idx int) {
|
|
pc.mu.Lock()
|
|
pc.setIndexForLane(lane, idx)
|
|
pc.mu.Unlock()
|
|
pc.notifyListeners()
|
|
}
|
|
|
|
// GetScrollOffsetForLane returns the scroll offset (top visible row) for a lane.
|
|
func (pc *PluginConfig) GetScrollOffsetForLane(lane int) int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
if lane < 0 || lane >= len(pc.scrollOffsets) {
|
|
return 0
|
|
}
|
|
return pc.scrollOffsets[lane]
|
|
}
|
|
|
|
// SetScrollOffsetForLane sets the scroll offset for a specific lane.
|
|
func (pc *PluginConfig) SetScrollOffsetForLane(lane int, offset int) {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
if lane < 0 || lane >= len(pc.scrollOffsets) {
|
|
return
|
|
}
|
|
pc.scrollOffsets[lane] = offset
|
|
}
|
|
|
|
func (pc *PluginConfig) SetSelectedLaneAndIndex(lane int, idx int) {
|
|
pc.mu.Lock()
|
|
if lane < 0 || lane >= len(pc.selectedIndices) {
|
|
pc.mu.Unlock()
|
|
return
|
|
}
|
|
if len(pc.selectedIndices) == 0 {
|
|
pc.mu.Unlock()
|
|
return
|
|
}
|
|
changed := pc.selectedLane != lane || pc.selectedIndices[lane] != idx
|
|
pc.selectedLane = lane
|
|
pc.selectedIndices[lane] = idx
|
|
pc.mu.Unlock()
|
|
|
|
if changed {
|
|
pc.notifyListeners()
|
|
}
|
|
}
|
|
|
|
// GetColumnsForLane returns the number of grid columns for a lane.
|
|
func (pc *PluginConfig) GetColumnsForLane(lane int) int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
return pc.columnsForLane(lane)
|
|
}
|
|
|
|
// AddSelectionListener registers a callback for selection changes
|
|
func (pc *PluginConfig) AddSelectionListener(listener PluginSelectionListener) int {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
id := pc.nextListenerID
|
|
pc.nextListenerID++
|
|
pc.listeners[id] = listener
|
|
return id
|
|
}
|
|
|
|
// RemoveSelectionListener removes a listener by ID
|
|
func (pc *PluginConfig) RemoveSelectionListener(id int) {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
delete(pc.listeners, id)
|
|
}
|
|
|
|
func (pc *PluginConfig) notifyListeners() {
|
|
pc.mu.RLock()
|
|
listeners := make([]PluginSelectionListener, 0, len(pc.listeners))
|
|
for _, l := range pc.listeners {
|
|
listeners = append(listeners, l)
|
|
}
|
|
pc.mu.RUnlock()
|
|
|
|
for _, l := range listeners {
|
|
l()
|
|
}
|
|
}
|
|
|
|
// MoveSelection moves selection in a direction within the current lane.
|
|
func (pc *PluginConfig) MoveSelection(direction string, taskCount int) bool {
|
|
if taskCount == 0 {
|
|
return false
|
|
}
|
|
|
|
pc.mu.Lock()
|
|
lane := pc.selectedLane
|
|
columns := pc.columnsForLane(lane)
|
|
oldIndex := pc.indexForLane(lane)
|
|
row := oldIndex / columns
|
|
col := oldIndex % columns
|
|
numRows := (taskCount + columns - 1) / columns
|
|
|
|
switch direction {
|
|
case "up":
|
|
if row > 0 {
|
|
pc.setIndexForLane(lane, oldIndex-columns)
|
|
}
|
|
case "down":
|
|
newIdx := oldIndex + columns
|
|
if row < numRows-1 && newIdx < taskCount {
|
|
pc.setIndexForLane(lane, newIdx)
|
|
}
|
|
case "left":
|
|
if col > 0 {
|
|
pc.setIndexForLane(lane, oldIndex-1)
|
|
}
|
|
case "right":
|
|
if col < columns-1 && oldIndex+1 < taskCount {
|
|
pc.setIndexForLane(lane, oldIndex+1)
|
|
}
|
|
}
|
|
|
|
moved := pc.indexForLane(lane) != oldIndex
|
|
pc.mu.Unlock()
|
|
|
|
if moved {
|
|
pc.notifyListeners()
|
|
}
|
|
return moved
|
|
}
|
|
|
|
// ClampSelection ensures selection is within bounds for the current lane.
|
|
func (pc *PluginConfig) ClampSelection(taskCount int) {
|
|
pc.mu.Lock()
|
|
lane := pc.selectedLane
|
|
index := pc.indexForLane(lane)
|
|
if index >= taskCount {
|
|
pc.setIndexForLane(lane, taskCount-1)
|
|
}
|
|
if pc.indexForLane(lane) < 0 {
|
|
pc.setIndexForLane(lane, 0)
|
|
}
|
|
pc.mu.Unlock()
|
|
}
|
|
|
|
// GetViewMode returns the current view mode
|
|
func (pc *PluginConfig) GetViewMode() ViewMode {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
return pc.viewMode
|
|
}
|
|
|
|
// ToggleViewMode switches between compact and expanded view modes
|
|
func (pc *PluginConfig) ToggleViewMode() {
|
|
pc.mu.Lock()
|
|
if pc.viewMode == ViewModeCompact {
|
|
pc.viewMode = ViewModeExpanded
|
|
} else {
|
|
pc.viewMode = ViewModeCompact
|
|
}
|
|
newMode := pc.viewMode
|
|
pluginName := pc.pluginName
|
|
configIndex := pc.configIndex
|
|
pc.mu.Unlock()
|
|
|
|
// Save to config (same pattern as BoardConfig)
|
|
if err := config.SavePluginViewMode(pluginName, configIndex, string(newMode)); err != nil {
|
|
slog.Error("failed to save plugin view mode", "plugin", pluginName, "error", err)
|
|
}
|
|
|
|
pc.notifyListeners()
|
|
}
|
|
|
|
// SetViewMode sets the view mode from a string value
|
|
func (pc *PluginConfig) SetViewMode(mode string) {
|
|
pc.mu.Lock()
|
|
defer pc.mu.Unlock()
|
|
|
|
if mode == "expanded" {
|
|
pc.viewMode = ViewModeExpanded
|
|
} else {
|
|
pc.viewMode = ViewModeCompact
|
|
}
|
|
}
|
|
|
|
// SavePreSearchState saves current selection for later restoration
|
|
func (pc *PluginConfig) SavePreSearchState() {
|
|
pc.mu.Lock()
|
|
pc.preSearchLane = pc.selectedLane
|
|
pc.preSearchIndices = ensureSelectionLength(pc.preSearchIndices, len(pc.laneColumns))
|
|
copy(pc.preSearchIndices, pc.selectedIndices)
|
|
selectedIndex := pc.indexForLane(pc.selectedLane)
|
|
pc.mu.Unlock()
|
|
pc.searchState.SavePreSearchState(selectedIndex)
|
|
}
|
|
|
|
// SetSearchResults sets filtered search results and query
|
|
func (pc *PluginConfig) SetSearchResults(results []task.SearchResult, query string) {
|
|
pc.searchState.SetSearchResults(results, query)
|
|
pc.notifyListeners()
|
|
}
|
|
|
|
// ClearSearchResults clears search and restores pre-search selection
|
|
func (pc *PluginConfig) ClearSearchResults() {
|
|
pc.searchState.ClearSearchResults()
|
|
pc.mu.Lock()
|
|
if len(pc.preSearchIndices) == len(pc.laneColumns) {
|
|
pc.selectedIndices = ensureSelectionLength(pc.selectedIndices, len(pc.laneColumns))
|
|
copy(pc.selectedIndices, pc.preSearchIndices)
|
|
pc.selectedLane = pc.preSearchLane
|
|
} else if len(pc.selectedIndices) > 0 {
|
|
pc.selectedLane = 0
|
|
pc.setIndexForLane(0, 0)
|
|
}
|
|
pc.mu.Unlock()
|
|
pc.notifyListeners()
|
|
}
|
|
|
|
// GetSearchResults returns current search results (nil if no search active)
|
|
func (pc *PluginConfig) GetSearchResults() []task.SearchResult {
|
|
return pc.searchState.GetSearchResults()
|
|
}
|
|
|
|
// IsSearchActive returns true if search is currently active
|
|
func (pc *PluginConfig) IsSearchActive() bool {
|
|
return pc.searchState.IsSearchActive()
|
|
}
|
|
|
|
// GetSearchQuery returns the current search query
|
|
func (pc *PluginConfig) GetSearchQuery() string {
|
|
return pc.searchState.GetSearchQuery()
|
|
}
|
|
|
|
func (pc *PluginConfig) indexForLane(lane int) int {
|
|
if len(pc.selectedIndices) == 0 {
|
|
return 0
|
|
}
|
|
if lane < 0 || lane >= len(pc.selectedIndices) {
|
|
slog.Warn("lane index out of range", "lane", lane, "count", len(pc.selectedIndices))
|
|
return 0
|
|
}
|
|
return pc.selectedIndices[lane]
|
|
}
|
|
|
|
func (pc *PluginConfig) setIndexForLane(lane int, idx int) {
|
|
if len(pc.selectedIndices) == 0 {
|
|
return
|
|
}
|
|
if lane < 0 || lane >= len(pc.selectedIndices) {
|
|
slog.Warn("lane index out of range", "lane", lane, "count", len(pc.selectedIndices))
|
|
return
|
|
}
|
|
pc.selectedIndices[lane] = idx
|
|
}
|
|
|
|
func (pc *PluginConfig) columnsForLane(lane int) int {
|
|
if len(pc.laneColumns) == 0 {
|
|
return 1
|
|
}
|
|
if lane < 0 || lane >= len(pc.laneColumns) {
|
|
slog.Warn("lane columns out of range", "lane", lane, "count", len(pc.laneColumns))
|
|
return 1
|
|
}
|
|
return pc.laneColumns[lane]
|
|
}
|
|
|
|
// GetWidthForLane returns the flex proportion for a lane.
|
|
func (pc *PluginConfig) GetWidthForLane(lane int) int {
|
|
pc.mu.RLock()
|
|
defer pc.mu.RUnlock()
|
|
if lane < 0 || lane >= len(pc.laneWidths) {
|
|
return 1
|
|
}
|
|
return pc.laneWidths[lane]
|
|
}
|
|
|
|
func normalizeLaneColumns(columns []int) []int {
|
|
if len(columns) == 0 {
|
|
return []int{1}
|
|
}
|
|
normalized := make([]int, len(columns))
|
|
for i, value := range columns {
|
|
if value <= 0 {
|
|
normalized[i] = 1
|
|
} else {
|
|
normalized[i] = value
|
|
}
|
|
}
|
|
return normalized
|
|
}
|
|
|
|
// normalizeLaneWidths converts user-specified width percentages into flex proportions.
|
|
// Lanes with width=0 (unspecified) get equal share of remaining space.
|
|
// If all widths are zero or widths is nil, all lanes get proportion 1 (equal).
|
|
func normalizeLaneWidths(widths []int, laneCount int) []int {
|
|
if laneCount <= 0 {
|
|
return []int{1}
|
|
}
|
|
result := make([]int, laneCount)
|
|
|
|
// count specified vs unspecified
|
|
specified := 0
|
|
specifiedSum := 0
|
|
for i := 0; i < laneCount; i++ {
|
|
if i < len(widths) && widths[i] > 0 {
|
|
specified++
|
|
specifiedSum += widths[i]
|
|
}
|
|
}
|
|
|
|
// all unspecified — equal proportions
|
|
if specified == 0 {
|
|
for i := range result {
|
|
result[i] = 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
// all specified — use as-is for proportions
|
|
if specified == laneCount {
|
|
for i := 0; i < laneCount; i++ {
|
|
result[i] = widths[i]
|
|
}
|
|
return result
|
|
}
|
|
|
|
// mixed: unspecified lanes split the remainder equally
|
|
unspecified := laneCount - specified
|
|
remaining := 100 - specifiedSum
|
|
if remaining <= 0 {
|
|
remaining = unspecified // fallback: 1% each
|
|
}
|
|
perUnspecified := remaining / unspecified
|
|
if perUnspecified <= 0 {
|
|
perUnspecified = 1
|
|
}
|
|
|
|
for i := 0; i < laneCount; i++ {
|
|
if i < len(widths) && widths[i] > 0 {
|
|
result[i] = widths[i]
|
|
} else {
|
|
result[i] = perUnspecified
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func ensureSelectionLength(current []int, size int) []int {
|
|
if size <= 0 {
|
|
return []int{}
|
|
}
|
|
if len(current) == size {
|
|
return current
|
|
}
|
|
next := make([]int, size)
|
|
copy(next, current)
|
|
return next
|
|
}
|