diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 075ae753..4513b2ce 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -16,7 +16,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v2 with: - go-version: 1.17 + go-version: 1.19 - name: fmt run: if [ "$(gofmt -s -l . | wc -l)" -gt 0 ]; then exit 1; fi diff --git a/internal/control/action/simple.go b/internal/control/action/simple.go index 76257a48..001eb1d5 100644 --- a/internal/control/action/simple.go +++ b/internal/control/action/simple.go @@ -1,5 +1,7 @@ package action +import "github.com/rs/zerolog/log" + // Simple implements the Action interface. // It models a simple, non-undoable action as a func() which is called on Do. type Simple struct { @@ -10,6 +12,17 @@ type Simple struct { // Do performs this simple action. // A simple action is not undoable. func (a *Simple) Do() { + if a.action == nil { // NOTE: this check is pointless for how I am using these... + explanation := func() string { + if a.explain == nil { + return "no explanation available" + } + return a.explain() + }() + log.Warn().Msgf("Simple action '%s' has no action function (is nil)", explanation) + return + } + a.action() } diff --git a/internal/control/cli/add.go b/internal/control/cli/add.go index b38001d3..629ca30b 100644 --- a/internal/control/cli/add.go +++ b/internal/control/cli/add.go @@ -7,8 +7,8 @@ import ( "github.com/ja-he/dayplan/internal/config" "github.com/ja-he/dayplan/internal/control" - "github.com/ja-he/dayplan/internal/filehandling" "github.com/ja-he/dayplan/internal/model" + "github.com/ja-he/dayplan/internal/storage" ) // AddCommand contains flags for the `summarize` command line command, for @@ -88,13 +88,13 @@ func (command *AddCommand) Execute(args []string) error { } type fileAndDay struct { - file *filehandling.FileHandler + file *storage.FileHandler data *model.Day date model.Date } toWrite := []fileAndDay{} - startDayFile := filehandling.NewFileHandler(envData.BaseDirPath + "/days/" + date.ToString()) + startDayFile := storage.NewFileHandler(envData.BaseDirPath + "/days/" + date.ToString()) startDay := startDayFile.Read([]model.Category{}) // we don't need the categories for this err = startDay.AddEvent( &model.Event{ @@ -131,7 +131,7 @@ func (command *AddCommand) Execute(args []string) error { current := dateIncrementer(date) for !current.IsAfter(repeatTilDate) { - currentDayFile := filehandling.NewFileHandler(envData.BaseDirPath + "/days/" + current.ToString()) + currentDayFile := storage.NewFileHandler(envData.BaseDirPath + "/days/" + current.ToString()) currentDay := currentDayFile.Read([]model.Category{}) // we don't need the categories for this err = currentDay.AddEvent( &model.Event{ diff --git a/internal/control/cli/controller.go b/internal/control/cli/controller.go index 1a475ee8..092981af 100644 --- a/internal/control/cli/controller.go +++ b/internal/control/cli/controller.go @@ -2,6 +2,9 @@ package cli import ( "fmt" + "math" + "os" + "path" "strconv" "sync" "time" @@ -11,11 +14,13 @@ import ( "github.com/ja-he/dayplan/internal/control" "github.com/ja-he/dayplan/internal/control/action" - "github.com/ja-he/dayplan/internal/filehandling" + "github.com/ja-he/dayplan/internal/control/edit" + "github.com/ja-he/dayplan/internal/control/edit/editors" "github.com/ja-he/dayplan/internal/input" "github.com/ja-he/dayplan/internal/input/processors" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/potatolog" + "github.com/ja-he/dayplan/internal/storage" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/tui" "github.com/ja-he/dayplan/internal/ui" @@ -34,7 +39,7 @@ func (t *Controller) GetDayFromFileHandler(date model.Date) *model.Day { tmp := fh.Read(t.data.Categories) return tmp } else { - newHandler := filehandling.NewFileHandler(t.data.EnvData.BaseDirPath + "/days/" + date.ToString()) + newHandler := storage.NewFileHandler(t.data.EnvData.BaseDirPath + "/days/" + date.ToString()) t.fhMutex.Lock() t.FileHandlers[date] = newHandler t.fhMutex.Unlock() @@ -43,12 +48,13 @@ func (t *Controller) GetDayFromFileHandler(date model.Date) *model.Day { } } +// Controller type Controller struct { data *control.ControlData - rootPane ui.Pane + rootPane *panes.RootPane fhMutex sync.RWMutex - FileHandlers map[model.Date]*filehandling.FileHandler + FileHandlers map[model.Date]*storage.FileHandler controllerEvents chan ControllerEvent // TODO: remove, obviously @@ -67,6 +73,7 @@ type Controller struct { syncer tui.ScreenSynchronizer } +// NewController func NewController( date model.Date, envData control.EnvData, @@ -77,37 +84,106 @@ func NewController( ) *Controller { controller := Controller{} + inputConfig := input.InputConfig{ + + Editor: map[input.Keyspec]input.Actionspec{ + "j": "next-field", + "k": "prev-field", + "i": "enter-subeditor", + ":w": "write", + "": "write-and-quit", + ":wq": "write-and-quit", + ":q!": "quit", + "": "quit", + }, + + StringEditor: input.ModedSpec{ + Normal: map[input.Keyspec]input.Actionspec{ + "h": "move-cursor-rune-left", + "l": "move-cursor-rune-right", + "": "move-cursor-rune-left", + "": "move-cursor-rune-right", + "0": "move-cursor-to-beginning", + "$": "move-cursor-to-end", + "": "quit", + "D": "delete-to-end", + "d$": "delete-to-end", + "C": "delete-to-end-and-insert", + "c$": "delete-to-end-and-insert", + "x": "delete-rune", + "s": "delete-rune-and-insert", + "i": "swap-mode-insert", + }, + Insert: map[input.Keyspec]input.Actionspec{ + "": "move-cursor-rune-left", + "": "move-cursor-rune-right", + "": "swap-mode-normal", + "": "backspace", + "": "backspace", + "": "backspace-to-beginning", + }, + }, + } + + categoryGetter := func(name string) model.Category { + cat, ok := categoryStyling.GetKnownCategoriesByName()[name] + if ok { + return *cat + } + return model.Category{ + Name: name, + } + } + controller.data = control.NewControlData(categoryStyling) + backlogFilePath := path.Join(envData.BaseDirPath, "days", "backlog.yml") // TODO(ja_he): Migrate 'days' -> 'data', perhaps subdir 'days' + backlog, err := func() (*model.Backlog, error) { + backlogReader, err := os.Open(backlogFilePath) + if err != nil { + return &model.Backlog{}, err + } + defer backlogReader.Close() + return model.BacklogFromReader(backlogReader, categoryGetter) + }() + if err != nil { + tuiLogger.Error().Err(err).Str("file", backlogFilePath).Msg("could not read backlog") + } else { + tuiLogger.Info().Str("file", backlogFilePath).Msg("successfully read backlog") + } + tuiLogger.Debug().Interface("backlog", backlog).Msg("backlog") + tasksWidth := 40 toolsWidth := 20 + rightFlexWidth := toolsWidth + statusHeight := 2 weatherWidth := 20 timelineWidth := 10 editorWidth := 80 editorHeight := 20 - scrollableZoomableInputMap := map[string]action.Action{ + scrollableZoomableInputMap := map[input.Keyspec]action.Action{ "": action.NewSimple(func() string { return "scoll up" }, func() { controller.ScrollUp(10) }), "": action.NewSimple(func() string { return "scroll down" }, func() { controller.ScrollDown(10) }), "gg": action.NewSimple(func() string { return "scroll to top" }, controller.ScrollTop), "G": action.NewSimple(func() string { return "scroll to bottom" }, controller.ScrollBottom), "+": action.NewSimple(func() string { return "zoom in" }, func() { - if controller.data.ViewParams.NRowsPerHour*2 <= 12 { - controller.data.ViewParams.NRowsPerHour *= 2 - controller.data.ViewParams.ScrollOffset *= 2 + if controller.data.MainTimelineViewParams.NRowsPerHour*2 <= 12 { + controller.data.MainTimelineViewParams.NRowsPerHour *= 2 + controller.data.MainTimelineViewParams.ScrollOffset *= 2 } }), "-": action.NewSimple(func() string { return "zoom out" }, func() { - if (controller.data.ViewParams.NRowsPerHour % 2) == 0 { - controller.data.ViewParams.NRowsPerHour /= 2 - controller.data.ViewParams.ScrollOffset /= 2 + if (controller.data.MainTimelineViewParams.NRowsPerHour % 2) == 0 { + controller.data.MainTimelineViewParams.NRowsPerHour /= 2 + controller.data.MainTimelineViewParams.ScrollOffset /= 2 } else { - log.Warn().Msg(fmt.Sprintf("can't decrease resolution below %d", controller.data.ViewParams.NRowsPerHour)) + log.Warn().Msg(fmt.Sprintf("can't decrease resolution below %d", controller.data.MainTimelineViewParams.NRowsPerHour)) } }), } - eventsViewBaseInputMap := map[string]action.Action{ + eventsViewBaseInputMap := map[input.Keyspec]action.Action{ "w": action.NewSimple(func() string { return "write day to file" }, controller.writeModel), "h": action.NewSimple(func() string { return "go to previous day" }, controller.goToPreviousDay), "l": action.NewSimple(func() string { return "go to next day" }, controller.goToNextDay), @@ -117,7 +193,7 @@ func NewController( } renderer := tui.NewTUIScreenHandler() - screenSize := renderer.GetScreenDimensions + screenSize := func() (w, h int) { _, _, w, h = renderer.Dimensions(); return } screenDimensions := func() (x, y, w, h int) { screenWidth, screenHeight := screenSize() return 0, 0, screenWidth, screenHeight @@ -130,18 +206,22 @@ func NewController( } helpDimensions := screenDimensions editorDimensions := centeredFloat(editorWidth, editorHeight) + tasksDimensions := func() (x, y, w, h int) { + screenWidth, screenHeight := screenSize() + return screenWidth - rightFlexWidth, 0, tasksWidth, screenHeight - statusHeight + } toolsDimensions := func() (x, y, w, h int) { - screenWidth, screeenHeight := screenSize() - return screenWidth - toolsWidth, 0, toolsWidth, screeenHeight - statusHeight + screenWidth, screenHeight := screenSize() + return screenWidth - toolsWidth, 0, toolsWidth, screenHeight - statusHeight } statusDimensions := func() (x, y, w, h int) { - screenWidth, screeenHeight := screenSize() - return 0, screeenHeight - statusHeight, screenWidth, statusHeight + screenWidth, screenHeight := screenSize() + return 0, screenHeight - statusHeight, screenWidth, statusHeight } dayViewMainPaneDimensions := screenDimensions dayViewScrollablePaneDimensions := func() (x, y, w, h int) { parentX, parentY, parentW, parentH := dayViewMainPaneDimensions() - return parentX, parentY, parentW - toolsWidth, parentH - statusHeight + return parentX, parentY, parentW - rightFlexWidth, parentH - statusHeight } weekViewMainPaneDimensions := screenDimensions monthViewMainPaneDimensions := screenDimensions @@ -180,7 +260,7 @@ func NewController( } weekdayPane := func(dayIndex int) *panes.EventsPane { return panes.NewEventsPane( - tui.NewConstrainedRenderer(renderer, weekdayDimensions(dayIndex)), + ui.NewConstrainedRenderer(renderer, weekdayDimensions(dayIndex)), weekdayDimensions(dayIndex), stylesheet, processors.NewModalInputProcessor(weekdayPaneInputTree), @@ -188,7 +268,7 @@ func NewController( return controller.data.Days.GetDay(controller.data.CurrentDate.GetDayInWeek(dayIndex)) }, categoryStyling.GetStyle, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, &controller.data.CursorPos, 0, false, @@ -217,7 +297,7 @@ func NewController( return controller.data.CurrentDate.GetDayInMonth(dayIndex).Month == controller.data.CurrentDate.Month }, panes.NewEventsPane( - tui.NewConstrainedRenderer(renderer, monthdayDimensions(dayIndex)), + ui.NewConstrainedRenderer(renderer, monthdayDimensions(dayIndex)), monthdayDimensions(dayIndex), stylesheet, processors.NewModalInputProcessor(monthdayPaneInputTree), @@ -225,7 +305,7 @@ func NewController( return controller.data.Days.GetDay(controller.data.CurrentDate.GetDayInMonth(dayIndex)) }, categoryStyling.GetStyle, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, &controller.data.CursorPos, 0, false, @@ -249,7 +329,7 @@ func NewController( } statusPane := panes.NewStatusPane( - tui.NewConstrainedRenderer(renderer, statusDimensions), + ui.NewConstrainedRenderer(renderer, statusDimensions), statusDimensions, stylesheet, &controller.data.CurrentDate, @@ -308,11 +388,241 @@ func NewController( } }, func() int { return timelineWidth }, - func() control.EventEditMode { return controller.data.EventEditMode }, + func() edit.EventEditMode { return controller.data.EventEditMode }, ) + var currentTask *model.Task + setCurrentTask := func(t *model.Task) { currentTask = t } + backlogViewParams := ui.BacklogViewParams{ + NRowsPerHour: &controller.data.MainTimelineViewParams.NRowsPerHour, + ScrollOffset: 0, + } + var ensureBacklogTaskVisible func(t *model.Task) + var scrollBacklogTop func() + var scrollBacklogBottom func() + var backlogSetCurrentToTopmost func() + var backlogSetCurrentToBottommost func() + var getBacklogBottomScrollOffset func() int + var offsetCurrentTask func(tl []*model.Task, setToNext bool) bool + popAndScheduleCurrentTask := func(when *time.Time) { + // pass nil time to not schedule + if currentTask == nil { + return + } + scheduledTask := currentTask + prev, next, parentage, err := backlog.Pop(scheduledTask) + if err != nil { + log.Error(). + Err(err). + Interface("task", currentTask). + Interface("backlog", backlog). + Msg("could not find task") + } else { + // update current task + currentTask = func() *model.Task { + switch { + case next != nil: + return next + case prev != nil: + return prev + case len(parentage) > 0: + return parentage[0] + default: + return nil + } + }() + // schedule task, if time for that was given + if when != nil { + namePrefix := "" + for _, parent := range parentage { + namePrefix = parent.Name + ": " + namePrefix + } + newEvents := scheduledTask.ToEvent(*when, namePrefix) + for _, newEvent := range newEvents { + controller.data.GetCurrentDay().AddEvent(newEvent) + } + } + } + } + tasksInputTree, err := input.ConstructInputTree( + map[input.Keyspec]action.Action{ + "": action.NewSimple(func() string { return "scroll up" }, func() { + backlogViewParams.SetScrollOffset(backlogViewParams.GetScrollOffset() - 10) + if backlogViewParams.GetScrollOffset() < 0 { + scrollBacklogTop() + } + }), + "": action.NewSimple(func() string { return "scroll down" }, func() { + scrollTarget := backlogViewParams.GetScrollOffset() + 10 + if scrollTarget > getBacklogBottomScrollOffset() { + scrollBacklogBottom() + } else { + backlogViewParams.SetScrollOffset(scrollTarget) + } + }), + "j": action.NewSimple(func() string { return "go down a task" }, func() { + if currentTask == nil { + if len(backlog.Tasks) > 0 { + currentTask = backlog.Tasks[0] + } + return + } + + found := offsetCurrentTask(backlog.Tasks, true) + if !found { + setCurrentTask(nil) + } + ensureBacklogTaskVisible(currentTask) + }), + "k": action.NewSimple(func() string { return "go up a task" }, func() { + if currentTask == nil { + if len(backlog.Tasks) > 0 { + currentTask = backlog.Tasks[0] + } + return + } + + found := offsetCurrentTask(backlog.Tasks, false) + if !found { + setCurrentTask(nil) + } + ensureBacklogTaskVisible(currentTask) + }), + "gg": action.NewSimple(func() string { return "scroll to top" }, func() { + backlogSetCurrentToTopmost() + }), + "G": action.NewSimple(func() string { return "scroll to bottom" }, func() { + backlogSetCurrentToBottommost() + }), + "sn": action.NewSimple(func() string { return "schedule now" }, func() { + when := time.Now() + popAndScheduleCurrentTask(&when) + }), + "d": action.NewSimple(func() string { return "delete task" }, func() { + popAndScheduleCurrentTask(nil) + }), + "l": action.NewSimple(func() string { return "step into subtasks" }, func() { + if currentTask == nil { + return + } + if len(currentTask.Subtasks) > 0 { + currentTask = currentTask.Subtasks[0] + ensureBacklogTaskVisible(currentTask) + } else { + log.Debug().Msg("current task has no subtasks, so remaining at it") + } + }), + "h": action.NewSimple(func() string { return "step out to parent task" }, func() { + var findParent func(searchedTask *model.Task, parent *model.Task, tasks []*model.Task) *model.Task + findParent = func(searchedTask *model.Task, parent *model.Task, parentsTasks []*model.Task) *model.Task { + for _, t := range parentsTasks { + if t == searchedTask { + return parent + } + maybeParent := findParent(searchedTask, t, t.Subtasks) + if maybeParent != nil { + return maybeParent + } + } + return nil + } + maybeParent := findParent(currentTask, nil, backlog.Tasks) + if maybeParent != nil { + setCurrentTask(maybeParent) + ensureBacklogTaskVisible(currentTask) + } else { + log.Debug().Msg("could not find parent, so not changing current task") + } + }), + "o": action.NewSimple(func() string { return "add a new task below the current one" }, func() { + if currentTask == nil { + log.Debug().Msgf("asked to add a task after to nil current task, adding as first") + newTask := backlog.AddLast() + newTask.Name = "(need to implement task editor)" + newTask.Category = controller.data.CurrentCategory + currentTask = newTask + return + } + newTask, parent, err := backlog.AddAfter(currentTask) + if err != nil { + log.Error().Err(err).Msgf("was unable to add a task after '%s'", currentTask.Name) + return + } + newTask.Name = "(need to implement task editor)" + if parent != nil { + newTask.Category = parent.Category + } else { + newTask.Category = controller.data.CurrentCategory + } + }), + "i": action.NewSimple(func() string { return "add a new subtask of the current task" }, func() { + if currentTask == nil { + log.Warn().Msgf("asked to add a subtask to nil current task") + return + } + currentTask.Subtasks = append(currentTask.Subtasks, &model.Task{ + Name: "(need to implement task editor)", + Category: currentTask.Category, + }) + }), + "": action.NewSimple(func() string { return "begin editing of task" }, func() { + if controller.data.TaskEditor != nil { + log.Warn().Msg("apparently, task editor was still active when a new one was activated, unexpected / error") + } + var err error + controller.data.TaskEditor, err = editors.ConstructEditor(currentTask, nil) + if err != nil { + log.Error().Err(err).Interface("current-task", currentTask).Msg("was not able to construct editor for current task") + return + } + taskEditorPane, err := controller.data.TaskEditor.GetPane( + ui.NewConstrainedRenderer(renderer, func() (x, y, w, h int) { + screenWidth, screenHeight := screenSize() + taskEditorBoxWidth := int(math.Min(80, float64(screenWidth))) + taskEditorBoxHeight := int(math.Min(20, float64(screenHeight))) + return (screenWidth / 2) - (taskEditorBoxWidth / 2), (screenHeight / 2) - (taskEditorBoxHeight / 2), taskEditorBoxWidth, taskEditorBoxHeight + }), + func() bool { return true }, + inputConfig, + stylesheet, + renderer, + ) + if err != nil { + log.Error().Err(err).Msgf("could not construct task editor pane") + controller.data.TaskEditor = nil + return + } + controller.rootPane.PushSubpane(taskEditorPane) + taskEditorDone := make(chan struct{}) + controller.data.TaskEditor.AddQuitCallback(func() { + close(taskEditorDone) + }) + go func() { + <-taskEditorDone + controller.controllerEvents <- ControllerEventTaskEditorExit + }() + }), + "w": action.NewSimple(func() string { return "store backlog to file" }, func() { + writer, err := os.OpenFile(backlogFilePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) + if err != nil { + log.Error().Err(err).Msgf("unable to write open backlog file '%s' for writing", backlogFilePath) + return + } + defer writer.Close() + err = backlog.Write(writer) + if err != nil { + log.Error().Err(err).Msg("unable to write backlog to writer") + return + } + log.Info().Msgf("wrote backlog to '%s' sucessfully", backlogFilePath) + }), + }, + ) + if err != nil { + stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for tasks pane") + } toolsInputTree, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "j": action.NewSimple(func() string { return "switch to next category" }, func() { for i, cat := range controller.data.Categories { if cat == controller.data.CurrentCategory { @@ -340,32 +650,33 @@ func NewController( } // TODO(ja-he): move elsewhere - ensureVisible := func(time model.Timestamp) { - topRowTime := controller.data.ViewParams.TimeAtY(0) + ensureEventsPaneTimestampVisible := func(time model.Timestamp) { + topRowTime := controller.data.MainTimelineViewParams.TimeAtY(0) if topRowTime.IsAfter(time) { - controller.data.ViewParams.ScrollOffset += (controller.data.ViewParams.YForTime(time)) + controller.data.MainTimelineViewParams.ScrollOffset += (controller.data.MainTimelineViewParams.YForTime(time)) } _, _, _, maxY := dayViewEventsPaneDimensions() - bottomRowTime := controller.data.ViewParams.TimeAtY(maxY) + bottomRowTime := controller.data.MainTimelineViewParams.TimeAtY(maxY) if time.IsAfter(bottomRowTime) { - controller.data.ViewParams.ScrollOffset += ((controller.data.ViewParams.YForTime(time)) - maxY) + controller.data.MainTimelineViewParams.ScrollOffset += ((controller.data.MainTimelineViewParams.YForTime(time)) - maxY) } } var startMovePushing func() + var pushEditorAsRootSubpane func() // TODO: directly? - eventsPaneDayInputExtension := map[string]action.Action{ + eventsPaneDayInputExtension := map[input.Keyspec]action.Action{ "j": action.NewSimple(func() string { return "switch to next event" }, func() { controller.data.GetCurrentDay().CurrentNext() if controller.data.GetCurrentDay().Current != nil { - ensureVisible(controller.data.GetCurrentDay().Current.Start) - ensureVisible(controller.data.GetCurrentDay().Current.End) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.Start) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.End) } }), "k": action.NewSimple(func() string { return "switch to previous event" }, func() { controller.data.GetCurrentDay().CurrentPrev() if controller.data.GetCurrentDay().Current != nil { - ensureVisible(controller.data.GetCurrentDay().Current.End) - ensureVisible(controller.data.GetCurrentDay().Current.Start) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.End) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.Start) } }), "d": action.NewSimple(func() string { return "delete selected event" }, func() { @@ -379,6 +690,7 @@ func NewController( if event != nil { controller.data.EventEditor.Activate(event) } + pushEditorAsRootSubpane() }), "o": action.NewSimple(func() string { return "add event after selected" }, func() { current := controller.data.GetCurrentDay().Current @@ -387,7 +699,8 @@ func NewController( Cat: controller.data.CurrentCategory, } if current == nil { - newEvent.Start = model.NewTimestampFromGotime(time.Now()).Snap(controller.data.ViewParams.MinutesPerRow()) + newEvent.Start = model.NewTimestampFromGotime(time.Now()). + Snap(int(controller.data.MainTimelineViewParams.DurationOfHeight(1) / time.Minute)) } else { newEvent.Start = current.End } @@ -398,7 +711,7 @@ func NewController( newEvent.End = newEvent.Start.OffsetMinutes(60) } controller.data.GetCurrentDay().AddEvent(newEvent) - ensureVisible(newEvent.End) + ensureEventsPaneTimestampVisible(newEvent.End) }), "O": action.NewSimple(func() string { return "add event before selected" }, func() { current := controller.data.GetCurrentDay().Current @@ -407,7 +720,8 @@ func NewController( Cat: controller.data.CurrentCategory, } if current == nil { - newEvent.End = model.NewTimestampFromGotime(time.Now()).Snap(controller.data.ViewParams.MinutesPerRow()) + newEvent.End = model.NewTimestampFromGotime(time.Now()). + Snap(int(controller.data.MainTimelineViewParams.DurationOfHeight(1) / time.Minute)) } else { newEvent.End = current.Start } @@ -418,7 +732,7 @@ func NewController( newEvent.Start = newEvent.End.OffsetMinutes(-60) } controller.data.GetCurrentDay().AddEvent(newEvent) - ensureVisible(newEvent.Start) + ensureEventsPaneTimestampVisible(newEvent.Start) }), "": action.NewSimple(func() string { return "add event now" }, func() { newEvent := &model.Event{ @@ -433,7 +747,7 @@ func NewController( newEvent.End = newEvent.Start.OffsetMinutes(60) } controller.data.GetCurrentDay().AddEvent(newEvent) - ensureVisible(newEvent.Start) + ensureEventsPaneTimestampVisible(newEvent.Start) }), "sn": action.NewSimple(func() string { return "split selected event now" }, func() { current := controller.data.GetCurrentDay().Current @@ -453,7 +767,7 @@ func NewController( }), "M": action.NewSimple(func() string { return "start move pushing" }, func() { startMovePushing() }), } - eventsPaneDayInputMap := make(map[string]action.Action) + eventsPaneDayInputMap := make(map[input.Keyspec]action.Action) for input, action := range eventsViewBaseInputMap { eventsPaneDayInputMap[input] = action } @@ -465,8 +779,21 @@ func NewController( stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for day view pane's events subpane") } + tasksVisible := false + toolsVisible := true + tasksPane := panes.NewBacklogPane( + ui.NewConstrainedRenderer(renderer, tasksDimensions), + tasksDimensions, + stylesheet, + processors.NewModalInputProcessor(tasksInputTree), + &backlogViewParams, + func() *model.Task { return currentTask }, + backlog, + categoryStyling.GetStyle, + func() bool { return tasksVisible }, + ) toolsPane := panes.NewToolsPane( - tui.NewConstrainedRenderer(renderer, toolsDimensions), + ui.NewConstrainedRenderer(renderer, toolsDimensions), toolsDimensions, stylesheet, processors.NewModalInputProcessor(toolsInputTree), @@ -475,15 +802,16 @@ func NewController( 2, 1, 0, + func() bool { return toolsVisible }, ) dayEventsPane := panes.NewEventsPane( - tui.NewConstrainedRenderer(renderer, dayViewEventsPaneDimensions), + ui.NewConstrainedRenderer(renderer, dayViewEventsPaneDimensions), dayViewEventsPaneDimensions, stylesheet, processors.NewModalInputProcessor(dayViewEventsPaneInputTree), controller.data.GetCurrentDay, categoryStyling.GetStyle, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, &controller.data.CursorPos, 2, true, @@ -499,34 +827,34 @@ func NewController( } overlay, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "n": action.NewSimple(func() string { return "move to now" }, func() { panic("TODO") }), "j": action.NewSimple(func() string { return "move down" }, func() { err := controller.data.GetCurrentDay().MoveEventsPushingBy( controller.data.GetCurrentDay().Current, - controller.data.ViewParams.MinutesPerRow(), - controller.data.ViewParams.MinutesPerRow(), + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), ) if err != nil { panic(err) } else { - ensureVisible(controller.data.GetCurrentDay().Current.End) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.End) } }), "k": action.NewSimple(func() string { return "move up" }, func() { err := controller.data.GetCurrentDay().MoveEventsPushingBy( controller.data.GetCurrentDay().Current, - -controller.data.ViewParams.MinutesPerRow(), - controller.data.ViewParams.MinutesPerRow(), + -int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), ) if err != nil { panic(err) } else { - ensureVisible(controller.data.GetCurrentDay().Current.Start) + ensureEventsPaneTimestampVisible(controller.data.GetCurrentDay().Current.Start) } }), - "M": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), - "": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), + "M": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), + "": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), // TODO(ja-he): mode switching }, ) @@ -534,7 +862,76 @@ func NewController( panic(err.Error()) } dayEventsPane.ApplyModalOverlay(input.CapturingOverlayWrap(overlay)) - controller.data.EventEditMode = control.EventEditModeMove + controller.data.EventEditMode = edit.EventEditModeMove + } + ensureBacklogTaskVisible = func(t *model.Task) { + viewportLB, viewportUB := tasksPane.GetTaskVisibilityBounds() + taskLB, taskUB := tasksPane.GetTaskUIYBounds(t) + if taskLB < viewportLB { + backlogViewParams.SetScrollOffset(backlogViewParams.GetScrollOffset() - (viewportLB - taskLB)) + } else if taskUB > viewportUB { + backlogViewParams.SetScrollOffset(backlogViewParams.GetScrollOffset() - (viewportUB - taskUB)) + } + } + scrollBacklogTop = func() { + backlogViewParams.SetScrollOffset(0) + } + scrollBacklogBottom = func() { + backlogViewParams.SetScrollOffset(getBacklogBottomScrollOffset()) + } + getBacklogBottomScrollOffset = func() int { + if len(backlog.Tasks) == 0 { + return 0 + } + lastTask := backlog.Tasks[len(backlog.Tasks)-1] + currentScrollOffset := backlogViewParams.GetScrollOffset() + _, tUB := tasksPane.GetTaskUIYBounds(lastTask) + _, vUB := tasksPane.GetTaskVisibilityBounds() + desiredScrollDelta := vUB - tUB - 1 + return currentScrollOffset - desiredScrollDelta + } + backlogSetCurrentToTopmost = func() { + if len(backlog.Tasks) == 0 { + return + } + currentTask = backlog.Tasks[0] + scrollBacklogTop() + } + backlogSetCurrentToBottommost = func() { + if len(backlog.Tasks) == 0 { + return + } + currentTask = backlog.Tasks[len(backlog.Tasks)-1] + scrollBacklogBottom() + } + offsetCurrentTask = func(tl []*model.Task, setToNext bool) bool { + if len(tl) == 0 { + return false + } + + for i, t := range tl { + if currentTask == t { + if setToNext { + if i < len(tl)-1 { + setCurrentTask(tl[i+1]) + } else { + log.Debug().Msg("not allowing selecting next task, as at last task in scope") + } + } else { + if i > 0 { + setCurrentTask(tl[i-1]) + } else { + log.Debug().Msg("not allowing selecting previous task, as at first task in scope") + } + } + return true + } + if offsetCurrentTask(t.Subtasks, setToNext) { + return true + } + } + + return false } dayViewEventsPaneInputTree.Root.Children[input.Key{Key: tcell.KeyRune, Ch: 'm'}] = &input.Node{Action: action.NewSimple(func() string { return "enter event move mode" }, func() { @@ -543,33 +940,41 @@ func NewController( } eventMoveOverlay, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "n": action.NewSimple(func() string { return "move to now" }, func() { current := controller.data.GetCurrentDay().Current newStart := *model.NewTimestampFromGotime(time.Now()) controller.data.GetCurrentDay().MoveSingleEventTo(current, newStart) - ensureVisible(current.Start) - ensureVisible(current.End) + ensureEventsPaneTimestampVisible(current.Start) + ensureEventsPaneTimestampVisible(current.End) }), "j": action.NewSimple(func() string { return "move down" }, func() { current := controller.data.GetCurrentDay().Current - controller.data.GetCurrentDay().MoveSingleEventBy(current, controller.data.ViewParams.MinutesPerRow(), controller.data.ViewParams.MinutesPerRow()) - ensureVisible(current.End) + controller.data.GetCurrentDay().MoveSingleEventBy( + current, + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) + ensureEventsPaneTimestampVisible(current.End) }), "k": action.NewSimple(func() string { return "move up" }, func() { current := controller.data.GetCurrentDay().Current - controller.data.GetCurrentDay().MoveSingleEventBy(current, -controller.data.ViewParams.MinutesPerRow(), controller.data.ViewParams.MinutesPerRow()) - ensureVisible(current.Start) + controller.data.GetCurrentDay().MoveSingleEventBy( + current, + -int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) + ensureEventsPaneTimestampVisible(current.Start) }), - "m": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), - "": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), + "m": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), + "": action.NewSimple(func() string { return "exit move mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), }, ) if err != nil { panic(err.Error()) } dayEventsPane.ApplyModalOverlay(input.CapturingOverlayWrap(eventMoveOverlay)) - controller.data.EventEditMode = control.EventEditModeMove + controller.data.EventEditMode = edit.EventEditModeMove })} dayViewEventsPaneInputTree.Root.Children[input.Key{Key: tcell.KeyRune, Ch: 'r'}] = &input.Node{Action: action.NewSimple(func() string { return "enter event resize mode" }, func() { if controller.data.GetCurrentDay().Current == nil { @@ -577,46 +982,58 @@ func NewController( } eventResizeOverlay, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "n": action.NewSimple(func() string { return "resize to now" }, func() { current := controller.data.GetCurrentDay().Current newEnd := *model.NewTimestampFromGotime(time.Now()) controller.data.GetCurrentDay().ResizeTo(current, newEnd) - ensureVisible(newEnd) + ensureEventsPaneTimestampVisible(newEnd) }), "j": action.NewSimple(func() string { return "increase size (lengthen)" }, func() { var err error current := controller.data.GetCurrentDay().Current - err = controller.data.GetCurrentDay().ResizeBy(current, controller.data.ViewParams.MinutesPerRow()) + err = controller.data.GetCurrentDay().ResizeBy( + current, + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) if err != nil { log.Warn().Err(err).Msg("unable to resize") } - err = controller.data.GetCurrentDay().SnapEnd(current, controller.data.ViewParams.MinutesPerRow()) + err = controller.data.GetCurrentDay().SnapEnd( + current, + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) if err != nil { log.Warn().Err(err).Msg("unable to snap") } - ensureVisible(current.End) + ensureEventsPaneTimestampVisible(current.End) }), "k": action.NewSimple(func() string { return "decrease size (shorten)" }, func() { current := controller.data.GetCurrentDay().Current - controller.data.GetCurrentDay().ResizeBy(current, -controller.data.ViewParams.MinutesPerRow()) - controller.data.GetCurrentDay().SnapEnd(current, controller.data.ViewParams.MinutesPerRow()) - ensureVisible(current.End) + controller.data.GetCurrentDay().ResizeBy( + current, + -int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) + controller.data.GetCurrentDay().SnapEnd( + current, + int(controller.data.MainTimelineViewParams.DurationOfHeight(1)/time.Minute), + ) + ensureEventsPaneTimestampVisible(current.End) }), - "r": action.NewSimple(func() string { return "exit resize mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), - "": action.NewSimple(func() string { return "exit resize mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = control.EventEditModeNormal }), + "r": action.NewSimple(func() string { return "exit resize mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), + "": action.NewSimple(func() string { return "exit resize mode" }, func() { dayEventsPane.PopModalOverlay(); controller.data.EventEditMode = edit.EventEditModeNormal }), }, ) if err != nil { stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for event pane's resize mode") } dayEventsPane.ApplyModalOverlay(input.CapturingOverlayWrap(eventResizeOverlay)) - controller.data.EventEditMode = control.EventEditModeResize + controller.data.EventEditMode = edit.EventEditModeResize })} var helpContentRegister func() rootPaneInputTree, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "q": action.NewSimple(func() string { return "exit program (unsaved progress is lost)" }, func() { controller.controllerEvents <- ControllerEventExit }), "P": action.NewSimple(func() string { return "show debug perf pane" }, func() { controller.data.ShowDebug = !controller.data.ShowDebug }), "S": action.NewSimple(func() string { return "open summary" }, func() { controller.data.ShowSummary = true }), @@ -630,11 +1047,37 @@ func NewController( if err != nil { stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for root pane") } + var ensureDayViewMainPaneFocusIsOnVisible func() + updateMainPaneRightFlexWidth := func() { + rightFlexWidth = 0 + if tasksPane.IsVisible() { + rightFlexWidth += tasksWidth + } + if toolsPane.IsVisible() { + rightFlexWidth += toolsWidth + } + } + toggleToolsPane := func() { + toolsVisible = !toolsVisible + if !toolsVisible { + ensureDayViewMainPaneFocusIsOnVisible() + } + updateMainPaneRightFlexWidth() + } + toggleTasksPane := func() { + tasksVisible = !tasksVisible + if !tasksVisible { + ensureDayViewMainPaneFocusIsOnVisible() + } + updateMainPaneRightFlexWidth() + } var dayViewFocusNext, dayViewFocusPrev func() dayViewInputTree, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "W": action.NewSimple(func() string { return "update weather" }, controller.updateWeather), + "t": action.NewSimple(func() string { return "toggle tools pane" }, toggleToolsPane), + "T": action.NewSimple(func() string { return "toggle tasks pane" }, toggleTasksPane), "h": action.NewSimple(func() string { return "switch to previous pane" }, func() { dayViewFocusPrev() }), "l": action.NewSimple(func() string { return "switch to next pane" }, func() { dayViewFocusNext() }), }, @@ -651,7 +1094,7 @@ func NewController( []ui.Pane{ dayEventsPane, panes.NewTimelinePane( - tui.NewConstrainedRenderer(renderer, dayViewTimelineDimensions), + ui.NewConstrainedRenderer(renderer, dayViewTimelineDimensions), dayViewTimelineDimensions, stylesheet, controller.data.GetCurrentSuntimes, @@ -662,15 +1105,15 @@ func NewController( return nil } }, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, ), panes.NewWeatherPane( - tui.NewConstrainedRenderer(renderer, weatherDimensions), + ui.NewConstrainedRenderer(renderer, weatherDimensions), weatherDimensions, stylesheet, &controller.data.CurrentDate, &controller.data.Weather, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, ), }, []ui.Pane{ @@ -703,16 +1146,19 @@ func NewController( dayViewMainPane := panes.NewWrapperPane( []ui.Pane{ dayViewScrollablePane, + tasksPane, toolsPane, statusPane, }, []ui.Pane{ dayViewScrollablePane, + tasksPane, toolsPane, }, processors.NewModalInputProcessor(dayViewInputTree), ) - weekViewMainPaneInputTree, err := input.ConstructInputTree(map[string]action.Action{}) + ensureDayViewMainPaneFocusIsOnVisible = dayViewMainPane.EnsureFocusIsOnVisible + weekViewMainPaneInputTree, err := input.ConstructInputTree(map[input.Keyspec]action.Action{}) if err != nil { stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for week view main pane") } @@ -720,12 +1166,12 @@ func NewController( []ui.Pane{ statusPane, panes.NewTimelinePane( - tui.NewConstrainedRenderer(renderer, weekViewTimelineDimensions), + ui.NewConstrainedRenderer(renderer, weekViewTimelineDimensions), weekViewTimelineDimensions, stylesheet, func() *model.SunTimes { return nil }, func() *model.Timestamp { return nil }, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, ), weekViewEventsWrapper, }, @@ -742,12 +1188,12 @@ func NewController( []ui.Pane{ statusPane, panes.NewTimelinePane( - tui.NewConstrainedRenderer(renderer, monthViewTimelineDimensions), + ui.NewConstrainedRenderer(renderer, monthViewTimelineDimensions), monthViewTimelineDimensions, stylesheet, func() *model.SunTimes { return nil }, func() *model.Timestamp { return nil }, - &controller.data.ViewParams, + &controller.data.MainTimelineViewParams, ), monthViewEventsWrapper, }, @@ -759,7 +1205,7 @@ func NewController( dayViewFocusNext = dayViewMainPane.FocusNext dayViewFocusPrev = dayViewMainPane.FocusPrev - summaryPaneInputTree, err := input.ConstructInputTree(map[string]action.Action{ + summaryPaneInputTree, err := input.ConstructInputTree(map[input.Keyspec]action.Action{ "S": action.NewSimple(func() string { return "close summary" }, func() { controller.data.ShowSummary = false }), "h": action.NewSimple(func() string { return "switch to previous day/week/month" }, func() { switch controller.data.ActiveView() { @@ -792,23 +1238,26 @@ func NewController( var editorStartInsertMode func() var editorLeaveInsertMode func() - editorInsertMode := processors.NewTextInputProcessor( // TODO rename - map[input.Key]action.Action{ - {Key: tcell.KeyESC}: action.NewSimple(func() string { return "exit insert mode" }, func() { editorLeaveInsertMode() }), - {Key: tcell.KeyCtrlA}: action.NewSimple(func() string { return "move cursor to beginning" }, controller.data.EventEditor.MoveCursorToBeginning), - {Key: tcell.KeyDelete}: action.NewSimple(func() string { return "delete character" }, controller.data.EventEditor.DeleteRune), - {Key: tcell.KeyCtrlD}: action.NewSimple(func() string { return "delete character" }, controller.data.EventEditor.DeleteRune), - {Key: tcell.KeyBackspace}: action.NewSimple(func() string { return "backspace" }, controller.data.EventEditor.BackspaceRune), - {Key: tcell.KeyBackspace2}: action.NewSimple(func() string { return "backspace" }, controller.data.EventEditor.BackspaceRune), - {Key: tcell.KeyCtrlE}: action.NewSimple(func() string { return "move cursor to end" }, controller.data.EventEditor.MoveCursorToEnd), - {Key: tcell.KeyCtrlU}: action.NewSimple(func() string { return "backspace to beginning" }, controller.data.EventEditor.BackspaceToBeginning), - {Key: tcell.KeyLeft}: action.NewSimple(func() string { return "move cursor left" }, controller.data.EventEditor.MoveCursorLeft), - {Key: tcell.KeyRight}: action.NewSimple(func() string { return "move cursor right" }, controller.data.EventEditor.MoveCursorRight), + editorInsertMode, err := processors.NewTextInputProcessor( // TODO rename + map[input.Keyspec]action.Action{ + "": action.NewSimple(func() string { return "exit insert mode" }, func() { editorLeaveInsertMode() }), + "": action.NewSimple(func() string { return "move cursor to beginning" }, controller.data.EventEditor.MoveCursorToBeginning), + "": action.NewSimple(func() string { return "delete character" }, controller.data.EventEditor.DeleteRune), + "": action.NewSimple(func() string { return "delete character" }, controller.data.EventEditor.DeleteRune), + "": action.NewSimple(func() string { return "backspace" }, controller.data.EventEditor.BackspaceRune), + "": action.NewSimple(func() string { return "backspace" }, controller.data.EventEditor.BackspaceRune), + "": action.NewSimple(func() string { return "move cursor to end" }, controller.data.EventEditor.MoveCursorToEnd), + "": action.NewSimple(func() string { return "backspace to beginning" }, controller.data.EventEditor.BackspaceToBeginning), + "": action.NewSimple(func() string { return "move cursor left" }, controller.data.EventEditor.MoveCursorLeft), + "": action.NewSimple(func() string { return "move cursor right" }, controller.data.EventEditor.MoveCursorRight), }, controller.data.EventEditor.AddRune, ) + if err != nil { + log.Fatal().Err(err).Msgf("could not construct editor insert mode processor") + } editorNormalModeTree, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "": action.NewSimple(func() string { return "abord edit, discard changes" }, controller.abortEdit), "": action.NewSimple(func() string { return "finish edit, commit changes" }, controller.endEdit), "i": action.NewSimple(func() string { return "enter insert mode" }, func() { editorStartInsertMode() }), @@ -843,7 +1292,7 @@ func NewController( stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for editor pane's normal mode") } helpPaneInputTree, err := input.ConstructInputTree( - map[string]action.Action{ + map[input.Keyspec]action.Action{ "?": action.NewSimple(func() string { return "close help" }, func() { controller.data.ShowHelp = false }), @@ -853,14 +1302,14 @@ func NewController( stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for help pane") } helpPane := panes.NewHelpPane( - tui.NewConstrainedRenderer(renderer, helpDimensions), + ui.NewConstrainedRenderer(renderer, helpDimensions), helpDimensions, stylesheet, func() bool { return controller.data.ShowHelp }, processors.NewModalInputProcessor(helpPaneInputTree), ) - editorPane := panes.NewEditorPane( - tui.NewConstrainedRenderer(renderer, editorDimensions), + editorPane := panes.NewEventEditorPane( + ui.NewConstrainedRenderer(renderer, editorDimensions), renderer, editorDimensions, stylesheet, @@ -870,6 +1319,7 @@ func NewController( func() int { return controller.data.EventEditor.CursorPos }, processors.NewModalInputProcessor(editorNormalModeTree), ) + pushEditorAsRootSubpane = func() { controller.rootPane.PushSubpane(editorPane) } editorStartInsertMode = func() { editorPane.ApplyModalOverlay(editorInsertMode) controller.data.EventEditor.SetMode(input.TextEditModeInsert) @@ -888,7 +1338,7 @@ func NewController( monthViewMainPane, panes.NewSummaryPane( - tui.NewConstrainedRenderer(renderer, screenDimensions), + ui.NewConstrainedRenderer(renderer, screenDimensions), screenDimensions, stylesheet, func() bool { return controller.data.ShowSummary }, @@ -935,7 +1385,7 @@ func NewController( processors.NewModalInputProcessor(summaryPaneInputTree), ), panes.NewLogPane( - tui.NewConstrainedRenderer(renderer, screenDimensions), + ui.NewConstrainedRenderer(renderer, screenDimensions), screenDimensions, stylesheet, func() bool { return controller.data.ShowLog }, @@ -943,10 +1393,9 @@ func NewController( &potatolog.GlobalMemoryLogReaderWriter, ), helpPane, - editorPane, panes.NewPerfPane( - tui.NewConstrainedRenderer(renderer, func() (x, y, w, h int) { return 2, 2, 50, 2 }), + ui.NewConstrainedRenderer(renderer, func() (x, y, w, h int) { return 2, 2, 50, 2 }), func() (x, y, w, h int) { return 2, 2, 50, 2 }, func() bool { return controller.data.ShowDebug }, &controller.data.RenderTimes, @@ -974,7 +1423,7 @@ func NewController( } controller.data.EventEditor.SetMode(input.TextEditModeNormal) - controller.data.EventEditMode = control.EventEditModeNormal + controller.data.EventEditMode = edit.EventEditModeNormal coordinatesProvided := (envData.Latitude != "" && envData.Longitude != "") owmApiKeyProvided := (envData.OwmApiKey != "") @@ -1015,8 +1464,8 @@ func NewController( controller.fhMutex.Lock() defer controller.fhMutex.Unlock() - controller.FileHandlers = make(map[model.Date]*filehandling.FileHandler) - controller.FileHandlers[date] = filehandling.NewFileHandler(controller.data.EnvData.BaseDirPath + "/days/" + date.ToString()) + controller.FileHandlers = make(map[model.Date]*storage.FileHandler) + controller.FileHandlers[date] = storage.NewFileHandler(controller.data.EnvData.BaseDirPath + "/days/" + date.ToString()) controller.data.CurrentDate = date if controller.FileHandlers[date] == nil { @@ -1032,21 +1481,21 @@ func NewController( controller.timestampGuesser = func(cursorX, cursorY int) model.Timestamp { _, yOffset, _, _ := dayViewEventsPaneDimensions() - return controller.data.ViewParams.TimeAtY(yOffset + cursorY) + return controller.data.MainTimelineViewParams.TimeAtY(yOffset + cursorY) } controller.initializedScreen = renderer controller.syncer = renderer - controller.data.MouseEditState = control.MouseEditStateNone + controller.data.MouseEditState = edit.MouseEditStateNone return &controller } func (t *Controller) ScrollUp(by int) { eventviewTopRow := 0 - if t.data.ViewParams.ScrollOffset-by >= eventviewTopRow { - t.data.ViewParams.ScrollOffset -= by + if t.data.MainTimelineViewParams.ScrollOffset-by >= eventviewTopRow { + t.data.MainTimelineViewParams.ScrollOffset -= by } else { t.ScrollTop() } @@ -1054,30 +1503,31 @@ func (t *Controller) ScrollUp(by int) { func (t *Controller) ScrollDown(by int) { eventviewBottomRow := t.tmpStatusYOffsetGetter() - if t.data.ViewParams.ScrollOffset+by+eventviewBottomRow <= (24 * t.data.ViewParams.NRowsPerHour) { - t.data.ViewParams.ScrollOffset += by + if t.data.MainTimelineViewParams.ScrollOffset+by+eventviewBottomRow <= (24 * t.data.MainTimelineViewParams.NRowsPerHour) { + t.data.MainTimelineViewParams.ScrollOffset += by } else { t.ScrollBottom() } } func (t *Controller) ScrollTop() { - t.data.ViewParams.ScrollOffset = 0 + t.data.MainTimelineViewParams.ScrollOffset = 0 } func (t *Controller) ScrollBottom() { eventviewBottomRow := t.tmpStatusYOffsetGetter() - t.data.ViewParams.ScrollOffset = 24*t.data.ViewParams.NRowsPerHour - eventviewBottomRow + t.data.MainTimelineViewParams.ScrollOffset = 24*t.data.MainTimelineViewParams.NRowsPerHour - eventviewBottomRow } func (t *Controller) abortEdit() { - t.data.MouseEditState = control.MouseEditStateNone + t.data.MouseEditState = edit.MouseEditStateNone t.data.MouseEditedEvent = nil t.data.EventEditor.Active = false + t.rootPane.PopSubpane() } func (t *Controller) endEdit() { - t.data.MouseEditState = control.MouseEditStateNone + t.data.MouseEditState = edit.MouseEditStateNone t.data.MouseEditedEvent = nil if t.data.EventEditor.Active { t.data.EventEditor.Active = false @@ -1085,24 +1535,25 @@ func (t *Controller) endEdit() { t.data.EventEditor.Original.Name = tmp.Name } t.data.GetCurrentDay().UpdateEventOrder() + t.rootPane.PopSubpane() } -func (t *Controller) startMouseMove(eventsInfo ui.EventsPanePositionInfo) { - t.data.MouseEditState = control.MouseEditStateMoving - t.data.MouseEditedEvent = eventsInfo.Event() - t.data.CurrentMoveStartingOffsetMinutes = eventsInfo.Event().Start.DurationInMinutesUntil(eventsInfo.Time()) +func (t *Controller) startMouseMove(eventsInfo *ui.EventsPanePositionInfo) { + t.data.MouseEditState = edit.MouseEditStateMoving + t.data.MouseEditedEvent = eventsInfo.Event + t.data.CurrentMoveStartingOffsetMinutes = eventsInfo.Event.Start.DurationInMinutesUntil(eventsInfo.Time) } -func (t *Controller) startMouseResize(eventsInfo ui.EventsPanePositionInfo) { - t.data.MouseEditState = control.MouseEditStateResizing - t.data.MouseEditedEvent = eventsInfo.Event() +func (t *Controller) startMouseResize(eventsInfo *ui.EventsPanePositionInfo) { + t.data.MouseEditState = edit.MouseEditStateResizing + t.data.MouseEditedEvent = eventsInfo.Event } -func (t *Controller) startMouseEventCreation(info ui.EventsPanePositionInfo) { +func (t *Controller) startMouseEventCreation(info *ui.EventsPanePositionInfo) { // find out cursor time - start := info.Time() + start := info.Time - log.Debug().Str("position-time", info.Time().ToString()).Msg("creation called") + log.Debug().Str("position-time", info.Time.ToString()).Msg("creation called") // create event at time with cat etc. e := model.Event{} @@ -1116,7 +1567,7 @@ func (t *Controller) startMouseEventCreation(info ui.EventsPanePositionInfo) { log.Error().Err(err).Interface("event", e).Msg("error occurred adding event") } else { t.data.MouseEditedEvent = &e - t.data.MouseEditState = control.MouseEditStateResizing + t.data.MouseEditState = edit.MouseEditStateResizing } } @@ -1226,11 +1677,10 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { buttons := e.Buttons() - paneType := positionInfo.PaneType() - switch paneType { - case ui.StatusPaneType: + switch positionInfo := positionInfo.(type) { + case *ui.StatusPanePositionInfo: - case ui.WeatherPaneType: + case *ui.WeatherPanePositionInfo: switch buttons { case tcell.WheelUp: t.ScrollUp(1) @@ -1238,7 +1688,7 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { t.ScrollDown(1) } - case ui.TimelinePaneType: + case *ui.TimelinePanePositionInfo: switch buttons { case tcell.WheelUp: t.ScrollUp(1) @@ -1246,24 +1696,24 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { t.ScrollDown(1) } - case ui.EventsPaneType: - eventsInfo := positionInfo.GetExtraEventsInfo() + case *ui.EventsPanePositionInfo: + eventsInfo := positionInfo // if button clicked, handle switch buttons { case tcell.Button3: - t.data.GetCurrentDay().RemoveEvent(eventsInfo.Event()) + t.data.GetCurrentDay().RemoveEvent(eventsInfo.Event) case tcell.Button2: - event := eventsInfo.Event() - if event != nil && eventsInfo.Time().IsAfter(event.Start) { - t.data.GetCurrentDay().SplitEvent(event, eventsInfo.Time()) + event := eventsInfo.Event + if event != nil && eventsInfo.Time.IsAfter(event.Start) { + t.data.GetCurrentDay().SplitEvent(event, eventsInfo.Time) } case tcell.Button1: // we've clicked while not editing // now we need to check where the cursor is and either start event // creation, resizing or moving - switch eventsInfo.EventBoxPart() { + switch eventsInfo.EventBoxPart { case ui.EventBoxNowhere: t.startMouseEventCreation(eventsInfo) case ui.EventBoxBottomRight: @@ -1271,7 +1721,7 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { case ui.EventBoxInterior: t.startMouseMove(eventsInfo) case ui.EventBoxTopEdge: - t.data.EventEditor.Activate(eventsInfo.Event()) + t.data.EventEditor.Activate(eventsInfo.Event) } case tcell.WheelUp: @@ -1282,11 +1732,11 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { } - case ui.ToolsPaneType: - toolsInfo := positionInfo.GetExtraToolsInfo() + case *ui.ToolsPanePositionInfo: + toolsInfo := positionInfo switch buttons { case tcell.Button1: - cat := toolsInfo.Category() + cat := toolsInfo.Category if cat != nil { t.data.CurrentCategory = *cat } @@ -1305,7 +1755,7 @@ func (t *Controller) handleMouseResizeEditEvent(ev tcell.Event) { switch buttons { case tcell.Button1: cursorTime := t.timestampGuesser(x, y) - visualCursorTime := cursorTime.OffsetMinutes(t.data.ViewParams.MinutesPerRow()) + visualCursorTime := cursorTime.OffsetMinutes(int(t.data.MainTimelineViewParams.DurationOfHeight(1) / time.Minute)) event := t.data.MouseEditedEvent var err error @@ -1358,6 +1808,7 @@ type ControllerEvent int const ( ControllerEventExit ControllerEvent = iota ControllerEventRender + ControllerEventTaskEditorExit ) // Empties all render events from the channel. @@ -1381,6 +1832,7 @@ func emptyRenderEvents(c chan ControllerEvent) bool { } } +// Run func (t *Controller) Run() { log.Info().Msg("dayplan TUI started") @@ -1411,6 +1863,17 @@ func (t *Controller) Run() { end := time.Now() t.data.RenderTimes.Add(uint64(end.Sub(start).Microseconds())) + + case ControllerEventTaskEditorExit: + if t.data.TaskEditor == nil { + log.Warn().Msgf("got task editor exit event, but no task editor active; likely logic error") + } else { + t.data.TaskEditor = nil + t.rootPane.PopSubpane() + log.Debug().Msgf("removed (presumed) task-editor subpane from root") + go func() { t.controllerEvents <- ControllerEventRender }() + } + case ControllerEventExit: return } @@ -1440,7 +1903,7 @@ func (t *Controller) Run() { switch e := ev.(type) { case *tcell.EventKey: t.data.MouseMode = false - t.data.MouseEditState = control.MouseEditStateNone + t.data.MouseEditState = edit.MouseEditStateNone key := input.KeyFromTcellEvent(e) inputApplied := t.rootPane.ProcessInput(key) @@ -1456,11 +1919,11 @@ func (t *Controller) Run() { t.updateCursorPos(x, y) switch t.data.MouseEditState { - case control.MouseEditStateNone: + case edit.MouseEditStateNone: t.handleMouseNoneEditEvent(e) - case control.MouseEditStateResizing: + case edit.MouseEditStateResizing: t.handleMouseResizeEditEvent(ev) - case control.MouseEditStateMoving: + case edit.MouseEditStateMoving: t.handleMouseMoveEditEvent(ev) } diff --git a/internal/control/cli/summarize.go b/internal/control/cli/summarize.go index 3c8463eb..849e0dc4 100644 --- a/internal/control/cli/summarize.go +++ b/internal/control/cli/summarize.go @@ -9,8 +9,8 @@ import ( "github.com/ja-he/dayplan/internal/config" "github.com/ja-he/dayplan/internal/control" - "github.com/ja-he/dayplan/internal/filehandling" "github.com/ja-he/dayplan/internal/model" + "github.com/ja-he/dayplan/internal/storage" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/util" ) @@ -94,7 +94,7 @@ func (command *SummarizeCommand) Execute(args []string) error { // TODO: can probably make this mostly async? days := make([]model.Day, 0) for currentDate != finalDate.Next() { - fh := filehandling.NewFileHandler(envData.BaseDirPath + "/days/" + currentDate.ToString()) + fh := storage.NewFileHandler(envData.BaseDirPath + "/days/" + currentDate.ToString()) categories := make([]model.Category, 0) for _, cat := range styledCategories.GetAll() { categories = append(categories, cat.Cat) diff --git a/internal/control/data.go b/internal/control/data.go index 99528193..91a60ea6 100644 --- a/internal/control/data.go +++ b/internal/control/data.go @@ -3,7 +3,8 @@ package control import ( "sync" - "github.com/ja-he/dayplan/internal/control/editor" + "github.com/ja-he/dayplan/internal/control/edit" + "github.com/ja-he/dayplan/internal/control/edit/editors" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/ui" @@ -81,13 +82,15 @@ type ControlData struct { CurrentDate model.Date Weather weather.Handler - EventEditor editor.EventEditor + EventEditor editors.EventEditor + TaskEditor edit.Editor + ShowLog bool ShowHelp bool ShowSummary bool ShowDebug bool - ViewParams ui.ViewParams + MainTimelineViewParams ui.SingleDayViewParams ActiveView func() ui.ActiveView @@ -95,35 +98,13 @@ type ControlData struct { EventProcessingTimes util.MetricsHandler MouseMode bool - EventEditMode EventEditMode + EventEditMode edit.EventEditMode - MouseEditState MouseEditState + MouseEditState edit.MouseEditState MouseEditedEvent *model.Event CurrentMoveStartingOffsetMinutes int } -type MouseEditState int - -const ( - _ MouseEditState = iota - MouseEditStateNone - MouseEditStateMoving - MouseEditStateResizing -) - -func (s MouseEditState) toString() string { - return "TODO" -} - -type EventEditMode = int - -const ( - _ EventEditMode = iota - EventEditModeNormal - EventEditModeMove - EventEditModeResize -) - type DaysData struct { daysMutex sync.RWMutex days map[model.Date]DayWithInfo @@ -141,8 +122,8 @@ func NewControlData(cs styling.CategoryStyling) *ControlData { t.Categories = append(t.Categories, style.Cat) } - t.ViewParams.NRowsPerHour = 6 - t.ViewParams.ScrollOffset = 8 * t.ViewParams.NRowsPerHour + t.MainTimelineViewParams.NRowsPerHour = 6 + t.MainTimelineViewParams.ScrollOffset = 8 * t.MainTimelineViewParams.NRowsPerHour return &t } diff --git a/internal/control/edit/editor.go b/internal/control/edit/editor.go new file mode 100644 index 00000000..b6f5825d --- /dev/null +++ b/internal/control/edit/editor.go @@ -0,0 +1,35 @@ +// Package edit implements generic interfaces for editing of objects (by the +// user). +package edit + +import ( + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" +) + +// Editor is an interface for editing of objects (by the user). +type Editor interface { + GetName() string + + // Write the state of the editor. + Write() + + // Quit the editor. + Quit() + + // AddQuitCallback adds a callback that is called when the editor is quit. + AddQuitCallback(func()) + + // GetFieldCount returns the number of fields of the editor. + GetFieldCount() int + + // GetPane returns a pane that represents this editor. + GetPane( + renderer ui.ConstrainedRenderer, + visible func() bool, + inputConfig input.InputConfig, + stylesheet styling.Stylesheet, + cursorController ui.TextCursorController, + ) (ui.Pane, error) +} diff --git a/internal/control/edit/editor_test.go b/internal/control/edit/editor_test.go new file mode 100644 index 00000000..1ffd8658 --- /dev/null +++ b/internal/control/edit/editor_test.go @@ -0,0 +1,42 @@ +package edit_test + +// import ( +// "fmt" +// "os" +// "testing" +// "time" +// +// "github.com/rs/zerolog" +// "github.com/rs/zerolog/log" +// +// "github.com/ja-he/dayplan/internal/control/editor" +// "github.com/ja-he/dayplan/internal/model" +// ) +// +// func TestContstructEditor(t *testing.T) { +// log.Logger = log.Output(zerolog.ConsoleWriter{ +// NoColor: true, +// Out: os.Stdout, +// PartsExclude: []string{"time"}, +// }) +// +// task := model.Task{ +// Name: "Asdfg", +// Category: model.Category{ +// Name: "Catsanddogs", +// Priority: 0, +// Goal: nil, +// }, +// Duration: func() *time.Duration { d := 30 * time.Minute; return &d }(), +// Deadline: func() *time.Time { +// r, _ := time.Parse("2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05+07:00") +// return &r +// }(), +// Subtasks: []*model.Task{}, +// } +// e, err := editor.ConstructEditor(&task, nil) +// if err != nil { +// panic(err) +// } +// fmt.Println(e) +// } diff --git a/internal/control/edit/editors/composite_editor.go b/internal/control/edit/editors/composite_editor.go new file mode 100644 index 00000000..9e51bab3 --- /dev/null +++ b/internal/control/edit/editors/composite_editor.go @@ -0,0 +1,264 @@ +package editors + +import ( + "fmt" + "reflect" + "strings" + + "github.com/rs/zerolog/log" + + "github.com/ja-he/dayplan/internal/control/action" + "github.com/ja-he/dayplan/internal/control/edit" + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/input/processors" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" + "github.com/ja-he/dayplan/internal/ui/panes" +) + +// Composite implements Editor +type Composite struct { + fields []edit.Editor + activeFieldIndex int + inField bool + + name string + quitCallback func() +} + +// SwitchToNextField switches to the next field (wrapping araound, if necessary) +func (e *Composite) SwitchToNextField() { + // TODO: should _somehow_ signal deactivate to active field + e.activeFieldIndex = (e.activeFieldIndex + 1) % len(e.fields) +} + +// SwitchToPrevField switches to the previous field (wrapping araound, if necessary) +func (e *Composite) SwitchToPrevField() { + // TODO: should _somehow_ signal deactivate to active field + e.activeFieldIndex = (e.activeFieldIndex - 1 + len(e.fields)) % len(e.fields) +} + +// EnterField changes the editor to enter the currently selected field, e.g. +// such that input processing is deferred to the field. +func (e *Composite) EnterField() { + if e.inField { + log.Warn().Msgf("composite editor was prompted to enter a field despite alred being in a field; likely logic error") + } + e.inField = true +} + +// ConstructEditor constructs a new editor... +func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, error) { + structPtr := reflect.ValueOf(obj) + + if structPtr.Kind() != reflect.Ptr { + return nil, fmt.Errorf("must pass a ptr to contruct editor (was given %s)", structPtr.Type().String()) + } + if structPtr.IsNil() { + return nil, fmt.Errorf("must not pass nil ptr to contruct editor") + } + structValue := structPtr.Elem() + structType := structValue.Type() + if structValue.Kind() != reflect.Struct { + return nil, fmt.Errorf("must pass a struct (by ptr) contruct editor (was given %s (by ptr))", structType.String()) + } + + e := &Composite{ + fields: nil, + activeFieldIndex: 0, + name: "root", + } + + // go through all tags + for i := 0; i < structValue.NumField(); i++ { + field := structType.Field(i) + + // when 'dpedit' set ... + if tag, ok := field.Tag.Lookup("dpedit"); ok { + parts := strings.Split(tag, ",") + + // build the edit spec + editspec := dpedit{ + Name: parts[0], + } + if len(parts) == 2 { + switch parts[1] { + case "ignore": + editspec.Ignore = true + case "subedit": // NOTE: this is an idea, how it would be rendered is not yet imagined, might be prohibitive -> drop + editspec.Subedit = true + default: + return nil, fmt.Errorf("field '%s' has unknown setting in 'dpedit': '%s'", field.Name, parts[1]) + } + } else if len(parts) > 2 { + return nil, fmt.Errorf("field %d has too many (%d) parts in tag 'dpedit'", i, len(parts)) + } + + subeditorIndex := i + // add the corresponding data to e (if not ignored) + if !editspec.Ignore { + switch field.Type.Kind() { + case reflect.String: + f := structValue.Field(i) + e.fields = append(e.fields, &StringEditor{ + Name: editspec.Name, + Content: f.String(), + CursorPos: 0, + Active: func() bool { return e.inField && e.activeFieldIndex == subeditorIndex }, + QuitCallback: func() { + if e.activeFieldIndex == subeditorIndex { + e.inField = false + } + }, + Mode: input.TextEditModeNormal, + CommitFn: func(v string) { f.SetString(v) }, + }) + case reflect.Struct: + // TODO + log.Warn().Msgf("ignoring STRUCT '%s' tagged '%s' (ignore:%t) of type '%s'", field.Name, editspec.Name, editspec.Ignore, field.Type.String()) + case reflect.Ptr: + // TODO + log.Warn().Msgf("ignoring PTR '%s' tagged '%s' (ignore:%t) of type '%s'", field.Name, editspec.Name, editspec.Ignore, field.Type.String()) + default: + return nil, fmt.Errorf("unable to edit non-ignored field '%s' of type '%s'", field.Name, field.Type.Kind()) + } + } + } + + } + + return e, nil +} + +type dpedit struct { + Name string + Ignore bool + Subedit bool +} + +// GetName returns the name of the editor. +func (e *Composite) GetName() string { return e.name } + +// Write writes the content of the editor back to the underlying data structure +// by calling the write functions of all subeditors. +func (e *Composite) Write() { + for _, subeditor := range e.fields { + subeditor.Write() + } +} + +// AddQuitCallback adds a callback that is called when the editor is quit. +func (e *Composite) AddQuitCallback(f func()) { + if e.quitCallback != nil { + existingCallback := e.quitCallback + e.quitCallback = func() { + existingCallback() + f() + } + } else { + e.quitCallback = f + } +} + +// Quit quits all subeditors and calls the quit callback. +func (e *Composite) Quit() { + for _, subeditor := range e.fields { + subeditor.Quit() + } + if e.quitCallback != nil { + e.quitCallback() + } +} + +func (e *Composite) GetFieldCount() int { + count := 0 + for _, subeditor := range e.fields { + count += subeditor.GetFieldCount() + } + return count +} + +// GetPane constructs a pane for this composite editor (including all subeditors). +func (e *Composite) GetPane( + renderer ui.ConstrainedRenderer, + visible func() bool, + inputConfig input.InputConfig, + stylesheet styling.Stylesheet, + cursorController ui.TextCursorController, +) (ui.Pane, error) { + subpanes := []ui.Pane{} + + rollingOffsetX := 0 + for _, subeditor := range e.fields { + log.Debug().Msgf("constructing subpane for subeditor '%s'", subeditor.GetName()) + + rollingOffsetX += 1 // padding + + subeditorOffsetX := rollingOffsetX + + subeditorH := 0 + // height is at least 1, plus 1 plus padding for any extra + for i := 0; i < subeditor.GetFieldCount()-1; i++ { + // TODO: this doesn't account for sub-subeditors with multiple fields + subeditorH += 2 + } + subeditorH += 1 + + subeditorPane, err := subeditor.GetPane( + ui.NewConstrainedRenderer(renderer, func() (int, int, int, int) { + compositeX, compositeY, compositeW, _ := renderer.Dimensions() + subeditorX := (compositeX + subeditorOffsetX) + subeditorY := compositeY + 1 + subeditorW := compositeW - 2 + return subeditorX, subeditorY, subeditorW, subeditorH + }), + visible, + inputConfig, + stylesheet, + cursorController, + ) + if err != nil { + return nil, fmt.Errorf("error constructing subpane for subeditor '%s' (%s)", subeditor.GetName(), err.Error()) + } + subpanes = append(subpanes, subeditorPane) + + rollingOffsetX += subeditorH // adding space for subeditor (will be padded next) + } + inputProcessor, err := e.createInputProcessor(inputConfig) + if err != nil { + return nil, fmt.Errorf("could not construct input processor (%s)", err.Error()) + } + return panes.NewCompositeEditorPane( + renderer, + visible, + inputProcessor, + stylesheet, + subpanes, + func() int { return e.activeFieldIndex }, + func() bool { return e.inField }, + ), nil +} + +func (e *Composite) createInputProcessor(cfg input.InputConfig) (input.ModalInputProcessor, error) { + actionspecToFunc := map[input.Actionspec]func(){ + "next-field": e.SwitchToNextField, + "prev-field": e.SwitchToPrevField, + "enter-subeditor": e.EnterField, + "write": e.Write, + "write-and-quit": func() { e.Write(); e.Quit() }, + "quit": e.Quit, + } + + mappings := map[input.Keyspec]action.Action{} + for keyspec, actionspec := range cfg.Editor { + log.Debug().Msgf("adding mapping '%s' -> '%s'", keyspec, actionspec) + actionspecCopy := actionspec + mappings[keyspec] = action.NewSimple(func() string { return string(actionspecCopy) }, actionspecToFunc[actionspecCopy]) + } + inputTree, err := input.ConstructInputTree(mappings) + if err != nil { + return nil, fmt.Errorf("could not construct normal mode input tree: %w", err) + } + + return processors.NewModalInputProcessor(inputTree), nil +} diff --git a/internal/control/editor/event_editor.go b/internal/control/edit/editors/event_editor.go similarity index 99% rename from internal/control/editor/event_editor.go rename to internal/control/edit/editors/event_editor.go index bac00686..39d6d209 100644 --- a/internal/control/editor/event_editor.go +++ b/internal/control/edit/editors/event_editor.go @@ -1,4 +1,4 @@ -package editor +package editors import ( "strconv" diff --git a/internal/control/edit/editors/string_editor.go b/internal/control/edit/editors/string_editor.go new file mode 100644 index 00000000..9191f637 --- /dev/null +++ b/internal/control/edit/editors/string_editor.go @@ -0,0 +1,321 @@ +package editors + +import ( + "fmt" + "strconv" + + "github.com/ja-he/dayplan/internal/control/action" + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/input/processors" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" + "github.com/ja-he/dayplan/internal/ui/panes" + "github.com/rs/zerolog/log" +) + +// StringEditorControl allows manipulation of a string editor. +type StringEditorControl interface { + SetMode(m input.TextEditMode) + DeleteRune() + BackspaceRune() + BackspaceToBeginning() + DeleteToEnd() + Clear() + MoveCursorToBeginning() + MoveCursorToEnd() + MoveCursorPastEnd() + MoveCursorLeft() + MoveCursorRight() + MoveCursorRightA() + MoveCursorNextWordBeginning() + MoveCursorPrevWordBeginning() + MoveCursorNextWordEnd() + AddRune(newRune rune) +} + +// StringEditor ... +type StringEditor struct { + Name string + + Content string + CursorPos int + Mode input.TextEditMode + Active func() bool + + QuitCallback func() + + CommitFn func(string) +} + +// IsActive ... +func (e StringEditor) IsActive() bool { return e.Active() } + +// GetName returns the name of the editor. +func (e StringEditor) GetName() string { return e.Name } + +// GetContent returns the current (edited) contents. +func (e StringEditor) GetContent() string { return e.Content } + +// GetCursorPos returns the current cursor position in the string, 0 being +func (e StringEditor) GetCursorPos() int { return e.CursorPos } + +// GetMode returns the current mode of the editor. +func (e StringEditor) GetMode() input.TextEditMode { return e.Mode } + +// SetMode sets the current mode of the editor. +func (e *StringEditor) SetMode(m input.TextEditMode) { e.Mode = m } + +// DeleteRune deletes the rune at the cursor position. +func (e *StringEditor) DeleteRune() { + tmpStr := []rune(e.Content) + if e.CursorPos < len(tmpStr) { + preCursor := tmpStr[:e.CursorPos] + postCursor := tmpStr[e.CursorPos+1:] + + e.Content = string(append(preCursor, postCursor...)) + } +} + +// BackspaceRune deletes the rune before the cursor position. +func (e *StringEditor) BackspaceRune() { + if e.CursorPos > 0 { + tmpStr := []rune(e.Content) + preCursor := tmpStr[:e.CursorPos-1] + postCursor := tmpStr[e.CursorPos:] + + e.Content = string(append(preCursor, postCursor...)) + e.CursorPos-- + } +} + +// BackspaceToBeginning deletes all runes before the cursor position. +func (e *StringEditor) BackspaceToBeginning() { + nameAfterCursor := []rune(e.Content)[e.CursorPos:] + e.Content = string(nameAfterCursor) + e.CursorPos = 0 +} + +// DeleteToEnd deletes all runes after the cursor position. +func (e *StringEditor) DeleteToEnd() { + nameBeforeCursor := []rune(e.Content)[:e.CursorPos] + e.Content = string(nameBeforeCursor) +} + +// Clear deletes all runes in the editor. +func (e *StringEditor) Clear() { + e.Content = "" + e.CursorPos = 0 +} + +// MoveCursorToBeginning moves the cursor to the beginning of the string. +func (e *StringEditor) MoveCursorToBeginning() { + e.CursorPos = 0 +} + +// MoveCursorToEnd moves the cursor to the end of the string. +func (e *StringEditor) MoveCursorToEnd() { + e.CursorPos = len([]rune(e.Content)) - 1 +} + +// MoveCursorPastEnd moves the cursor past the end of the string. +func (e *StringEditor) MoveCursorPastEnd() { + e.CursorPos = len([]rune(e.Content)) +} + +// MoveCursorLeft moves the cursor one rune to the left. +func (e *StringEditor) MoveCursorLeft() { + if e.CursorPos > 0 { + e.CursorPos-- + } +} + +// MoveCursorRight moves the cursor one rune to the right. +func (e *StringEditor) MoveCursorRight() { + nameLen := len([]rune(e.Content)) + if e.CursorPos+1 < nameLen { + e.CursorPos++ + } +} + +// MoveCursorNextWordBeginning moves the cursor one rune to the right, or to +// the end of the string if already at the end. +func (e *StringEditor) MoveCursorNextWordBeginning() { + if len([]rune(e.Content)) == 0 { + e.CursorPos = 0 + return + } + + nameAfterCursor := []rune(e.Content)[e.CursorPos:] + i := 0 + for i < len(nameAfterCursor) && nameAfterCursor[i] != ' ' { + i++ + } + for i < len(nameAfterCursor) && nameAfterCursor[i] == ' ' { + i++ + } + newCursorPos := e.CursorPos + i + if newCursorPos < len([]rune(e.Content)) { + e.CursorPos = newCursorPos + } else { + e.MoveCursorToEnd() + } +} + +// MoveCursorPrevWordBeginning moves the cursor one rune to the left, or to the +// beginning of the string if already at the beginning. +func (e *StringEditor) MoveCursorPrevWordBeginning() { + nameBeforeCursor := []rune(e.Content)[:e.CursorPos] + if len(nameBeforeCursor) == 0 { + return + } + i := len(nameBeforeCursor) - 1 + for i > 0 && nameBeforeCursor[i-1] == ' ' { + i-- + } + for i > 0 && nameBeforeCursor[i-1] != ' ' { + i-- + } + e.CursorPos = i +} + +// MoveCursorNextWordEnd moves the cursor to the end of the next word. +func (e *StringEditor) MoveCursorNextWordEnd() { + nameAfterCursor := []rune(e.Content)[e.CursorPos:] + if len(nameAfterCursor) == 0 { + return + } + + i := 0 + for i < len(nameAfterCursor)-1 && nameAfterCursor[i+1] == ' ' { + i++ + } + for i < len(nameAfterCursor)-1 && nameAfterCursor[i+1] != ' ' { + i++ + } + newCursorPos := e.CursorPos + i + if newCursorPos < len([]rune(e.Content)) { + e.CursorPos = newCursorPos + } else { + e.MoveCursorToEnd() + } +} + +// AddRune adds a rune at the cursor position. +func (e *StringEditor) AddRune(newRune rune) { + if strconv.IsPrint(newRune) { + tmpName := []rune(e.Content) + cursorPos := e.CursorPos + if len(tmpName) == cursorPos { + tmpName = append(tmpName, newRune) + } else { + tmpName = append(tmpName[:cursorPos+1], tmpName[cursorPos:]...) + tmpName[cursorPos] = newRune + } + e.Content = string(tmpName) + e.CursorPos++ + } +} + +func (e *StringEditor) GetFieldCount() int { + return 1 +} + +// Write commits the current contents of the editor. +func (e *StringEditor) Write() { + e.CommitFn(e.Content) +} + +// Quit the editor. +func (e *StringEditor) Quit() { + e.QuitCallback() +} + +// AddQuitCallback adds a callback that is called when the editor is quit. +func (e *StringEditor) AddQuitCallback(f func()) { + if e.QuitCallback != nil { + existingCallback := e.QuitCallback + e.QuitCallback = func() { + existingCallback() + f() + } + } +} + +// GetPane returns a UI pane representing the editor. +func (e *StringEditor) GetPane( + renderer ui.ConstrainedRenderer, + visible func() bool, + inputConfig input.InputConfig, + stylesheet styling.Stylesheet, + cursorController ui.TextCursorController, +) (ui.Pane, error) { + inputProcessor, err := e.createInputProcessor(inputConfig) + if err != nil { + return nil, err + } + p := panes.NewStringEditorPane( + renderer, + visible, + inputProcessor, + e, + stylesheet, + cursorController, + ) + return p, nil +} + +func (e *StringEditor) createInputProcessor(cfg input.InputConfig) (input.ModalInputProcessor, error) { + + var enterInsertMode func() + var exitInsertMode func() + + actionspecToFunc := map[input.Actionspec]func(){ + "move-cursor-rune-left": e.MoveCursorLeft, + "move-cursor-rune-right": e.MoveCursorRight, + "move-cursor-to-beginning": e.MoveCursorToBeginning, + "move-cursor-to-end": e.MoveCursorToEnd, + "write": e.Write, + "quit": e.Quit, + "backspace": e.BackspaceRune, + "backspace-to-beginning": e.BackspaceToBeginning, + "delete-rune": e.DeleteRune, + "delete-rune-and-insert": func() { e.DeleteRune(); enterInsertMode() }, + "delete-to-end": e.DeleteToEnd, + "delete-to-end-and-insert": func() { e.DeleteToEnd(); enterInsertMode() }, + "swap-mode-insert": func() { enterInsertMode() }, + "swap-mode-normal": func() { exitInsertMode() }, + } + + normalModeMappings := map[input.Keyspec]action.Action{} + for keyspec, actionspec := range cfg.StringEditor.Normal { + actionspecCopy := actionspec + normalModeMappings[keyspec] = action.NewSimple(func() string { return string(actionspecCopy) }, actionspecToFunc[actionspecCopy]) + } + normalModeInputTree, err := input.ConstructInputTree(normalModeMappings) + if err != nil { + return nil, fmt.Errorf("could not construct normal mode input tree: %w", err) + } + + insertModeMappings := map[input.Keyspec]action.Action{} + for keyspec, actionspec := range cfg.StringEditor.Insert { + actionspecCopy := actionspec + insertModeMappings[keyspec] = action.NewSimple(func() string { return string(actionspecCopy) }, actionspecToFunc[actionspecCopy]) + } + insertModeInputTree, err := processors.NewTextInputProcessor(insertModeMappings, e.AddRune) + if err != nil { + return nil, fmt.Errorf("could not construct insert mode input processor: %w", err) + } + + p := processors.NewModalInputProcessor(normalModeInputTree) + enterInsertMode = func() { + log.Debug().Msgf("entering insert mode") + p.ApplyModalOverlay(insertModeInputTree) + } + exitInsertMode = func() { + log.Debug().Msgf("entering normal mode") + p.PopModalOverlay() + } + log.Debug().Msgf("attached mode swapping functions") + + return p, nil +} diff --git a/internal/control/edit/mode.go b/internal/control/edit/mode.go new file mode 100644 index 00000000..2708c0f6 --- /dev/null +++ b/internal/control/edit/mode.go @@ -0,0 +1,23 @@ +package edit + +type MouseEditState int + +const ( + _ MouseEditState = iota + MouseEditStateNone + MouseEditStateMoving + MouseEditStateResizing +) + +func (s MouseEditState) toString() string { + return "TODO" +} + +type EventEditMode = int + +const ( + _ EventEditMode = iota + EventEditModeNormal + EventEditModeMove + EventEditModeResize +) diff --git a/internal/control/edit/views/string_editor.go b/internal/control/edit/views/string_editor.go new file mode 100644 index 00000000..7b4613b7 --- /dev/null +++ b/internal/control/edit/views/string_editor.go @@ -0,0 +1,24 @@ +package views + +import "github.com/ja-he/dayplan/internal/input" + +// StringEditorView allows inspection of a string editor. +type StringEditorView interface { + + // IsActive signals whether THIS is active. (SHOULD BE MOVED TO A MORE GENERIC INTERFACE) + IsActive() bool + + // GetMode returns the current mode of the editor. + GetMode() input.TextEditMode + + // GetCursorPos returns the current cursor position in the string, 0 being + // the first character. + GetCursorPos() int + + // GetContent returns the current (edited) contents. + GetContent() string + + GetName() string + + // TODO: more +} diff --git a/internal/input/config_format.go b/internal/input/config_format.go index cfdc329c..774cc48c 100644 --- a/internal/input/config_format.go +++ b/internal/input/config_format.go @@ -1,167 +1,15 @@ package input -import ( - "fmt" - "unicode" +type Keyspec string +type Actionspec string +type Modename string - "github.com/gdamore/tcell/v2" -) - -// ConfigKeyspecToKeys converts full key sequence specification strings (e.g. -// "qw" meaning the SPACE key, then the Q key, then the W key) to the -// appropriate sequence of Keys (or an error, if invalid). -func ConfigKeyspecToKeys(spec string) ([]Key, error) { - specR := []rune(spec) - keys := make([][]rune, 0) - specialContext := false - - for pos := range spec { - switch spec[pos] { - - case '<': - if specialContext { - return nil, fmt.Errorf("illegal second opening special context ('<') before previous is closed (pos %d)", pos) - } - specialContext = true - keys = append(keys, []rune{specR[pos]}) - - case '>': - if !specialContext { - return nil, fmt.Errorf("illegal closing of special context ('>') while none open (pos %d)", pos) - } - specialContext = false - keys[len(keys)-1] = append(keys[len(keys)-1], specR[pos]) - - default: - if specialContext { - if !unicode.IsLetter(specR[pos]) && spec[pos] != '-' { - return nil, - fmt.Errorf("illegal character '%c' in special context (pos %d)", spec[pos], pos) - } - keys[len(keys)-1] = append(keys[len(keys)-1], specR[pos]) - } else { - keys = append(keys, []rune{specR[pos]}) - } - - } - } - - result := make([]Key, 0) - for _, keyIdentifier := range keys { - if keyIdentifier[0] == '<' { - key, err := KeyIdentifierToKey(string(keyIdentifier[1 : len(keyIdentifier)-1])) - if err != nil { - return nil, fmt.Errorf("error mapping identifier '%s' to key: %s", string(keyIdentifier), err.Error()) - } - result = append(result, key) - } else { - result = append(result, Key{Key: tcell.KeyRune, Ch: keyIdentifier[0]}) - } - } - - return result, nil +type InputConfig struct { + Editor map[Keyspec]Actionspec `yaml:"editor"` + StringEditor ModedSpec `yaml:"string-editor"` } -// KeyIdentifierToKey converts the given special identifier to the appropriate -// key (or an error, if invalid). -func KeyIdentifierToKey(identifier string) (Key, error) { - mapping := map[string]Key{ - "space": {Key: tcell.KeyRune, Ch: ' '}, - "cr": {Key: tcell.KeyEnter}, - "esc": {Key: tcell.KeyESC}, - "del": {Key: tcell.KeyDelete}, - "bs": {Key: tcell.KeyBackspace2}, - "left": {Key: tcell.KeyLeft}, - "right": {Key: tcell.KeyRight}, - - "c-space": {Key: tcell.KeyCtrlSpace}, - "c-bs": {Key: tcell.KeyBackspace}, - - "c-a": {Key: tcell.KeyCtrlA}, - "c-b": {Key: tcell.KeyCtrlB}, - "c-c": {Key: tcell.KeyCtrlC}, - "c-d": {Key: tcell.KeyCtrlD}, - "c-e": {Key: tcell.KeyCtrlE}, - "c-f": {Key: tcell.KeyCtrlF}, - "c-g": {Key: tcell.KeyCtrlG}, - "c-h": {Key: tcell.KeyCtrlH}, - "c-i": {Key: tcell.KeyCtrlI}, - "c-j": {Key: tcell.KeyCtrlJ}, - "c-k": {Key: tcell.KeyCtrlK}, - "c-l": {Key: tcell.KeyCtrlL}, - "c-m": {Key: tcell.KeyCtrlM}, - "c-n": {Key: tcell.KeyCtrlN}, - "c-o": {Key: tcell.KeyCtrlO}, - "c-p": {Key: tcell.KeyCtrlP}, - "c-q": {Key: tcell.KeyCtrlQ}, - "c-r": {Key: tcell.KeyCtrlR}, - "c-s": {Key: tcell.KeyCtrlS}, - "c-t": {Key: tcell.KeyCtrlT}, - "c-u": {Key: tcell.KeyCtrlU}, - "c-v": {Key: tcell.KeyCtrlV}, - "c-w": {Key: tcell.KeyCtrlW}, - "c-x": {Key: tcell.KeyCtrlX}, - "c-y": {Key: tcell.KeyCtrlY}, - "c-z": {Key: tcell.KeyCtrlZ}, - } - - key, ok := mapping[identifier] - if !ok { - return Key{}, fmt.Errorf("no mapping present for identifier '%s'", identifier) - } - - return key, nil -} - -// ToConfigIdentifierString converts the given key to its configuration -// identfier. -func ToConfigIdentifierString(k Key) string { - mapping := map[Key]string{ - {Key: tcell.KeyRune, Ch: ' '}: "space", - {Key: tcell.KeyEnter}: "cr", - {Key: tcell.KeyESC}: "esc", - {Key: tcell.KeyDelete}: "del", - {Key: tcell.KeyBackspace2}: "bs", - {Key: tcell.KeyLeft}: "left", - {Key: tcell.KeyRight}: "right", - - {Key: tcell.KeyCtrlSpace}: "c-space", - {Key: tcell.KeyBackspace}: "c-bs", - - {Key: tcell.KeyCtrlA}: "c-a", - {Key: tcell.KeyCtrlB}: "c-b", - {Key: tcell.KeyCtrlC}: "c-c", - {Key: tcell.KeyCtrlD}: "c-d", - {Key: tcell.KeyCtrlE}: "c-e", - {Key: tcell.KeyCtrlF}: "c-f", - {Key: tcell.KeyCtrlG}: "c-g", - {Key: tcell.KeyCtrlH}: "c-h", - {Key: tcell.KeyCtrlI}: "c-i", - {Key: tcell.KeyCtrlJ}: "c-j", - {Key: tcell.KeyCtrlK}: "c-k", - {Key: tcell.KeyCtrlL}: "c-l", - {Key: tcell.KeyCtrlM}: "c-m", - {Key: tcell.KeyCtrlN}: "c-n", - {Key: tcell.KeyCtrlO}: "c-o", - {Key: tcell.KeyCtrlP}: "c-p", - {Key: tcell.KeyCtrlQ}: "c-q", - {Key: tcell.KeyCtrlR}: "c-r", - {Key: tcell.KeyCtrlS}: "c-s", - {Key: tcell.KeyCtrlT}: "c-t", - {Key: tcell.KeyCtrlU}: "c-u", - {Key: tcell.KeyCtrlV}: "c-v", - {Key: tcell.KeyCtrlW}: "c-w", - {Key: tcell.KeyCtrlX}: "c-x", - {Key: tcell.KeyCtrlY}: "c-y", - {Key: tcell.KeyCtrlZ}: "c-z", - } - - identifier, ok := mapping[k] - if ok { - return "<" + identifier + ">" - } else if k.Key == tcell.KeyRune { - return string(k.Ch) - } else { - panic(fmt.Sprintf("undescribable key %s", k.ToDebugString())) - } +type ModedSpec struct { + Normal map[Keyspec]Actionspec `yaml:"normal"` + Insert map[Keyspec]Actionspec `yaml:"insert"` } diff --git a/internal/input/input_test.go b/internal/input/input_test.go index 9bfb4e5a..d0bf0c5a 100644 --- a/internal/input/input_test.go +++ b/internal/input/input_test.go @@ -12,7 +12,7 @@ import ( func TestConfigKeyspecToKey(t *testing.T) { t.Run("valid", func(t *testing.T) { - expectValid := func(s string) []input.Key { + expectValid := func(s input.Keyspec) []input.Key { keys, err := input.ConfigKeyspecToKeys(s) if err != nil { t.Error("unexpected error on valid spec:", err.Error()) @@ -84,7 +84,7 @@ func TestConfigKeyspecToKey(t *testing.T) { }) t.Run("valid", func(t *testing.T) { - expectInvalid := func(s string) error { + expectInvalid := func(s input.Keyspec) error { keys, err := input.ConfigKeyspecToKeys(s) if err == nil { t.Error("unexpectedly no err on invalid spec") @@ -163,7 +163,7 @@ func TestChild(t *testing.T) { func TestConstructInputTree(t *testing.T) { t.Run("empty map produces single-node tree", func(t *testing.T) { - emptyTree, err := input.ConstructInputTree(make(map[string]action.Action)) + emptyTree, err := input.ConstructInputTree(make(map[input.Keyspec]action.Action)) if err != nil { t.Error(err.Error()) } @@ -178,7 +178,7 @@ func TestConstructInputTree(t *testing.T) { t.Run("single input sequence", func(t *testing.T) { shouldGetSetToTrue := false - tree, err := input.ConstructInputTree(map[string]action.Action{"xyz": &DummyAction{F: func() { shouldGetSetToTrue = true }}}) + tree, err := input.ConstructInputTree(map[input.Keyspec]action.Action{"xyz": &DummyAction{F: func() { shouldGetSetToTrue = true }}}) if err != nil { t.Error(err.Error()) } @@ -216,7 +216,7 @@ func TestConstructInputTree(t *testing.T) { t.Run("complex inputs", func(t *testing.T) { xyzTrueable := false ctrlaTrueable := false - tree, err := input.ConstructInputTree(map[string]action.Action{ + tree, err := input.ConstructInputTree(map[input.Keyspec]action.Action{ "xyz": &DummyAction{F: func() { xyzTrueable = true }}, "": &DummyAction{F: func() { ctrlaTrueable = true }}, }) @@ -274,7 +274,7 @@ func TestConstructInputTree(t *testing.T) { }) t.Run("invalid keyspec errors", func(t *testing.T) { - tree, err := input.ConstructInputTree(map[string]action.Action{"qw" meaning the SPACE key, then the Q key, then the W key) to the +// appropriate sequence of Keys (or an error, if invalid). +func ConfigKeyspecToKeys(spec Keyspec) ([]Key, error) { + specR := []rune(spec) + keys := make([][]rune, 0) + specialContext := false + + for pos := range spec { + switch spec[pos] { + + case '<': + if specialContext { + return nil, fmt.Errorf("illegal second opening special context ('<') before previous is closed (pos %d)", pos) + } + specialContext = true + keys = append(keys, []rune{specR[pos]}) + + case '>': + if !specialContext { + return nil, fmt.Errorf("illegal closing of special context ('>') while none open (pos %d)", pos) + } + specialContext = false + keys[len(keys)-1] = append(keys[len(keys)-1], specR[pos]) + + default: + if specialContext { + if !unicode.IsLetter(specR[pos]) && spec[pos] != '-' { + return nil, + fmt.Errorf("illegal character '%c' in special context (pos %d)", spec[pos], pos) + } + keys[len(keys)-1] = append(keys[len(keys)-1], specR[pos]) + } else { + keys = append(keys, []rune{specR[pos]}) + } + + } + } + + result := make([]Key, 0) + for _, keyIdentifier := range keys { + if keyIdentifier[0] == '<' { + key, err := KeyIdentifierToKey(string(keyIdentifier[1 : len(keyIdentifier)-1])) + if err != nil { + return nil, fmt.Errorf("error mapping identifier '%s' to key: %s", string(keyIdentifier), err.Error()) + } + result = append(result, key) + } else { + result = append(result, Key{Key: tcell.KeyRune, Ch: keyIdentifier[0]}) + } + } + + return result, nil +} + +// KeyIdentifierToKey converts the given special identifier to the appropriate +// key (or an error, if invalid). +func KeyIdentifierToKey(identifier string) (Key, error) { + identifier = strings.ToLower(identifier) + mapping := map[string]Key{ + "space": {Key: tcell.KeyRune, Ch: ' '}, + "cr": {Key: tcell.KeyEnter}, + "esc": {Key: tcell.KeyESC}, + "del": {Key: tcell.KeyDelete}, + "bs": {Key: tcell.KeyBackspace2}, + "left": {Key: tcell.KeyLeft}, + "right": {Key: tcell.KeyRight}, + + "c-space": {Key: tcell.KeyCtrlSpace}, + "c-bs": {Key: tcell.KeyBackspace}, + + "c-a": {Key: tcell.KeyCtrlA}, + "c-b": {Key: tcell.KeyCtrlB}, + "c-c": {Key: tcell.KeyCtrlC}, + "c-d": {Key: tcell.KeyCtrlD}, + "c-e": {Key: tcell.KeyCtrlE}, + "c-f": {Key: tcell.KeyCtrlF}, + "c-g": {Key: tcell.KeyCtrlG}, + "c-h": {Key: tcell.KeyCtrlH}, + "c-i": {Key: tcell.KeyCtrlI}, + "c-j": {Key: tcell.KeyCtrlJ}, + "c-k": {Key: tcell.KeyCtrlK}, + "c-l": {Key: tcell.KeyCtrlL}, + "c-m": {Key: tcell.KeyCtrlM}, + "c-n": {Key: tcell.KeyCtrlN}, + "c-o": {Key: tcell.KeyCtrlO}, + "c-p": {Key: tcell.KeyCtrlP}, + "c-q": {Key: tcell.KeyCtrlQ}, + "c-r": {Key: tcell.KeyCtrlR}, + "c-s": {Key: tcell.KeyCtrlS}, + "c-t": {Key: tcell.KeyCtrlT}, + "c-u": {Key: tcell.KeyCtrlU}, + "c-v": {Key: tcell.KeyCtrlV}, + "c-w": {Key: tcell.KeyCtrlW}, + "c-x": {Key: tcell.KeyCtrlX}, + "c-y": {Key: tcell.KeyCtrlY}, + "c-z": {Key: tcell.KeyCtrlZ}, + } + + key, ok := mapping[identifier] + if !ok { + return Key{}, fmt.Errorf("no mapping present for identifier '%s'", identifier) + } + + return key, nil +} + +// ToConfigIdentifierString converts the given key to its configuration +// identfier. +func ToConfigIdentifierString(k Key) string { + mapping := map[Key]string{ + {Key: tcell.KeyRune, Ch: ' '}: "space", + {Key: tcell.KeyEnter}: "cr", + {Key: tcell.KeyESC}: "esc", + {Key: tcell.KeyDelete}: "del", + {Key: tcell.KeyBackspace2}: "bs", + {Key: tcell.KeyLeft}: "left", + {Key: tcell.KeyRight}: "right", + + {Key: tcell.KeyCtrlSpace}: "c-space", + {Key: tcell.KeyBackspace}: "c-bs", + + {Key: tcell.KeyCtrlA}: "c-a", + {Key: tcell.KeyCtrlB}: "c-b", + {Key: tcell.KeyCtrlC}: "c-c", + {Key: tcell.KeyCtrlD}: "c-d", + {Key: tcell.KeyCtrlE}: "c-e", + {Key: tcell.KeyCtrlF}: "c-f", + {Key: tcell.KeyCtrlG}: "c-g", + {Key: tcell.KeyCtrlH}: "c-h", + {Key: tcell.KeyCtrlI}: "c-i", + {Key: tcell.KeyCtrlJ}: "c-j", + {Key: tcell.KeyCtrlK}: "c-k", + {Key: tcell.KeyCtrlL}: "c-l", + {Key: tcell.KeyCtrlM}: "c-m", + {Key: tcell.KeyCtrlN}: "c-n", + {Key: tcell.KeyCtrlO}: "c-o", + {Key: tcell.KeyCtrlP}: "c-p", + {Key: tcell.KeyCtrlQ}: "c-q", + {Key: tcell.KeyCtrlR}: "c-r", + {Key: tcell.KeyCtrlS}: "c-s", + {Key: tcell.KeyCtrlT}: "c-t", + {Key: tcell.KeyCtrlU}: "c-u", + {Key: tcell.KeyCtrlV}: "c-v", + {Key: tcell.KeyCtrlW}: "c-w", + {Key: tcell.KeyCtrlX}: "c-x", + {Key: tcell.KeyCtrlY}: "c-y", + {Key: tcell.KeyCtrlZ}: "c-z", + } + + identifier, ok := mapping[k] + if ok { + return "<" + identifier + ">" + } else if k.Key == tcell.KeyRune { + return string(k.Ch) + } else { + panic(fmt.Sprintf("undescribable key %s", k.ToDebugString())) + } +} diff --git a/internal/input/processors/processors_test.go b/internal/input/processors/processors_test.go index dd62762d..962f4c2a 100644 --- a/internal/input/processors/processors_test.go +++ b/internal/input/processors/processors_test.go @@ -184,12 +184,15 @@ func TestTextInputProcessor(t *testing.T) { t.Run("runes", func(t *testing.T) { r := rune(0) callback := func(newRune rune) { r = newRune } - p := processors.NewTextInputProcessor( - map[input.Key]action.Action{ - cY: &dummyAction{action: func() { t.Error("the cY callback was called, which it should not have been") }}, + p, err := processors.NewTextInputProcessor( + map[input.Keyspec]action.Action{ + "": &dummyAction{action: func() { t.Error("the cY callback was called, which it should not have been") }}, }, callback, ) + if err != nil { + panic(err) + } p.ProcessInput(x) if r != 'x' { @@ -208,13 +211,16 @@ func TestTextInputProcessor(t *testing.T) { t.Run("specials", func(t *testing.T) { cACalled := false cBCalled := false - p := processors.NewTextInputProcessor( - map[input.Key]action.Action{ - cA: &dummyAction{action: func() { cACalled = true }}, - cB: &dummyAction{action: func() { cBCalled = true }}, + p, err := processors.NewTextInputProcessor( + map[input.Keyspec]action.Action{ + "": &dummyAction{action: func() { cACalled = true }}, + "": &dummyAction{action: func() { cBCalled = true }}, }, func(rune) {}, ) + if err != nil { + panic(err) + } if !p.ProcessInput(cA) || !cACalled { t.Error("action for not done") @@ -230,29 +236,38 @@ func TestTextInputProcessor(t *testing.T) { }) t.Run("CapturesInput", func(t *testing.T) { - p := processors.NewTextInputProcessor(map[input.Key]action.Action{}, func(rune) {}) + p, err := processors.NewTextInputProcessor(map[input.Keyspec]action.Action{}, func(rune) {}) + if err != nil { + panic(err) + } if !p.CapturesInput() { t.Error("text input processor does not unconditionally capture input") } }) t.Run("GetHelp", func(t *testing.T) { - p := processors.NewTextInputProcessor( - map[input.Key]action.Action{ - cA: &dummyAction{explanation: "Aaa"}, - cB: &dummyAction{explanation: "Bbb"}, + p, err := processors.NewTextInputProcessor( + map[input.Keyspec]action.Action{ + "": &dummyAction{explanation: "Aaa"}, + "": &dummyAction{explanation: "Bbb"}, }, func(rune) {}, ) + if err != nil { + panic(err) + } help := p.GetHelp() if !(len(help) == 2 && help[input.ToConfigIdentifierString(cA)] == "Aaa" && help[input.ToConfigIdentifierString(cB)] == "Bbb") { t.Error("help looks unexpected:", help) } - p = processors.NewTextInputProcessor( - map[input.Key]action.Action{}, + p, err = processors.NewTextInputProcessor( + map[input.Keyspec]action.Action{}, func(rune) {}, ) + if err != nil { + panic(err) + } if len(p.GetHelp()) != 0 { t.Error("help on empty not empty") } diff --git a/internal/input/processors/text_input_processor.go b/internal/input/processors/text_input_processor.go index 5b000ef6..d7130af9 100644 --- a/internal/input/processors/text_input_processor.go +++ b/internal/input/processors/text_input_processor.go @@ -1,6 +1,8 @@ package processors import ( + "fmt" + "github.com/gdamore/tcell/v2" "github.com/ja-he/dayplan/internal/control/action" @@ -57,11 +59,22 @@ func (p *TextInputProcessor) GetHelp() input.Help { // NewTextInputProcessor returns a pointer to a new NewTextInputProcessor. func NewTextInputProcessor( - normalModeMappings map[input.Key]action.Action, + normalModeMappings map[input.Keyspec]action.Action, runeCallback func(r rune), -) *TextInputProcessor { +) (*TextInputProcessor, error) { + mappings := map[input.Key]action.Action{} + for keyspec, action := range normalModeMappings { + keys, err := input.ConfigKeyspecToKeys(keyspec) + if err != nil { + return nil, fmt.Errorf("could not convert '%s' to keys (%s)", keyspec, err.Error()) + } + if len(keys) != 1 { + return nil, fmt.Errorf("keyspec '%s' for text processor has not exactly one key (but %d)", keyspec, len(keys)) + } + mappings[keys[0]] = action + } return &TextInputProcessor{ - mappings: normalModeMappings, + mappings: mappings, runeCallback: runeCallback, - } + }, nil } diff --git a/internal/input/text_edit_mode.go b/internal/input/text_edit_mode.go index b90d8cae..4d2f4b93 100644 --- a/internal/input/text_edit_mode.go +++ b/internal/input/text_edit_mode.go @@ -1,3 +1,4 @@ +// TODO: this should be moved to 'control/editor' imo package input // TextEditMode enumerates the possible modes of modal text editing. diff --git a/internal/input/tree.go b/internal/input/tree.go index 05788da0..ec59a467 100644 --- a/internal/input/tree.go +++ b/internal/input/tree.go @@ -55,7 +55,7 @@ func (t *Tree) CapturesInput() bool { // sequence strings to actions. // If the given mapping is invalid, this returns an error. func ConstructInputTree( - spec map[string]action.Action, + spec map[Keyspec]action.Action, ) (*Tree, error) { root := NewNode() diff --git a/internal/model/backlog.go b/internal/model/backlog.go new file mode 100644 index 00000000..919e6f57 --- /dev/null +++ b/internal/model/backlog.go @@ -0,0 +1,309 @@ +package model + +import ( + "fmt" + "io" + "sync" + "time" + + "github.com/rs/zerolog/log" + "gopkg.in/yaml.v3" +) + +// Backlog holds Tasks which can be popped out of the backlog to a concrete +// timeslot. +// Backlogging Tasks is a planning mechanism; the Backlog can be seen as a +// to-do list. +type Backlog struct { + Tasks []*Task + Mtx sync.RWMutex +} + +// A Task remains to be done (or dropped) but is not yet scheduled. +// It has a name and belongs to a category (by name); +// it can further have a duration (estimate), a deadline (due date) and +// subtasks. +type Task struct { + Name string `dpedit:"name"` + Category Category `dpedit:"category"` + Duration *time.Duration `dpedit:"duration"` + Deadline *time.Time `dpedit:"deadline"` + Subtasks []*Task `dpedit:",ignore"` +} + +func (t Task) toBaseTask() BaseTask { + result := BaseTask{ + Name: t.Name, + Duration: t.Duration, + Deadline: t.Deadline, + Subtasks: make([]BaseTask, 0, len(t.Subtasks)), + } + for _, subtask := range t.Subtasks { + if t.Category.Name != subtask.Category.Name { + log.Warn(). + Str("subtask", subtask.Name). + Str("parent-task", t.Name). + Str("subtask-category", subtask.Category.Name). + Str("parent-task-category", t.Category.Name). + Msg("subtask has different category from parent, which will be lost") + } + result.Subtasks = append(result.Subtasks, subtask.toBaseTask()) + } + return result +} + +// BacklogStored. +type BacklogStored struct { + TasksByCategory map[string][]BaseTask `yaml:",inline"` +} + +// BaseTask. +type BaseTask struct { + Name string `yaml:"name"` + Duration *time.Duration `yaml:"duration,omitempty"` + Deadline *time.Time `yaml:"deadline,omitempty"` + Subtasks []BaseTask `yaml:"subtasks,omitempty"` +} + +// Write writes the Backlog to the given io.Writer, e.g., an opened file. +func (b *Backlog) Write(w io.Writer) error { + + toBeWritten := BacklogStored{ + TasksByCategory: map[string][]BaseTask{}, + } + for _, task := range b.Tasks { + categoryName := task.Category.Name + toBeWritten.TasksByCategory[categoryName] = append( + toBeWritten.TasksByCategory[categoryName], + task.toBaseTask(), + ) + } + + data, err := yaml.Marshal(toBeWritten) + if err != nil { + return fmt.Errorf("unabel to marshal backlog (%s)", err.Error()) + } + _, err = w.Write(data) + if err != nil { + return fmt.Errorf("unable to write to backlog writer (%s)", err.Error()) + } + + return nil +} + +// Read reads and deserializes a backlog from the io.Reader and returns the +// backlog. +func BacklogFromReader(r io.Reader, categoryGetter func(string) Category) (*Backlog, error) { + data, err := io.ReadAll(r) + if err != nil { + return &Backlog{}, fmt.Errorf("unable to read from reader (%s)", err.Error()) + } + + stored := BacklogStored{} + err = yaml.Unmarshal(data, &stored) + if err != nil { + return &Backlog{}, fmt.Errorf("yaml unmarshaling error (%s)", err.Error()) + } + log.Debug().Int("N-Cats", len(stored.TasksByCategory)).Msg("read storeds") + + var mapSubtasks func(cat string, tasks []BaseTask) []*Task + toTask := func(cat string, b BaseTask) *Task { + return &Task{ + Name: b.Name, + Category: categoryGetter(cat), + Duration: b.Duration, + Deadline: b.Deadline, + Subtasks: mapSubtasks(cat, b.Subtasks), + } + } + mapSubtasks = func(cat string, tasks []BaseTask) []*Task { + result := []*Task{} + for _, t := range tasks { + result = append(result, toTask(cat, t)) + } + return result + } + + b := &Backlog{Tasks: []*Task{}} + for cat, tasks := range stored.TasksByCategory { + for _, task := range tasks { + b.Tasks = append(b.Tasks, toTask(cat, task)) + } + } + + return b, nil +} + +// Pop the given task out from wherever it is in this backlog, returning +// that location (by surrounding tasks and parentage). +func (b *Backlog) Pop(task *Task) (prev *Task, next *Task, parentage []*Task, err error) { + var indexAmongTasks int + prev, next, parentage, indexAmongTasks, err = b.Locate(task) + if err != nil { + return + } + parentTasks := func() *[]*Task { + if len(parentage) > 0 { + return &parentage[0].Subtasks + } else { + return &b.Tasks + } + }() + + b.Mtx.Lock() + defer b.Mtx.Unlock() + *parentTasks = append((*parentTasks)[:indexAmongTasks], (*parentTasks)[indexAmongTasks+1:]...) + return +} + +// Locate the given task, i.e. give its neighbors and parentage. +// Returns an error when the task cannot be found. +func (b *Backlog) Locate(task *Task) (prev *Task, next *Task, parentage []*Task, index int, err error) { + + var locateRecursive func(t *Task, l []*Task, p []*Task) (prev *Task, next *Task, parentage []*Task, index int, err error) + locateRecursive = func(t *Task, l []*Task, p []*Task) (prev *Task, next *Task, parentage []*Task, index int, err error) { + for i, currentTask := range l { + if currentTask == t { + if i > 0 { + prev = l[i-1] + } + if i < len(l)-1 { + next = l[i+1] + } + parentage = p + index = i + err = nil + return + } + maybePrev, maybeNext, maybeParentage, maybeIndex, maybeErr := locateRecursive(t, currentTask.Subtasks, append([]*Task{currentTask}, p...)) + if maybeErr == nil { + prev, next, parentage, index, err = maybePrev, maybeNext, maybeParentage, maybeIndex, maybeErr + return + } + } + + return nil, nil, nil, -1, fmt.Errorf("not found") + } + + b.Mtx.RLock() + defer b.Mtx.RUnlock() + return locateRecursive(task, b.Tasks, nil) +} + +// AddFirst +func (b *Backlog) AddLast() *Task { + newTask := new(Task) + b.Tasks = append(b.Tasks, newTask) + return newTask +} + +// AddAfter adds a new task after the given anchorTask. +func (b *Backlog) AddAfter(anchorTask *Task) (newTask *Task, parent *Task, err error) { + _, _, parentage, index, err := b.Locate(anchorTask) + if err != nil { + return nil, nil, fmt.Errorf("could not locate anchor task (%s)", err.Error()) + } + taskList := b.Tasks + if len(parentage) > 0 { + parent = parentage[0] + taskList = parent.Subtasks + } + + // sanity check + { + if taskList[index] != anchorTask { + return nil, nil, fmt.Errorf("implementation error: task[%d].Name == '%s' != '%s", index, taskList[index].Name, anchorTask.Name) + } + } + + newTask = new(Task) + + // insert new task after given index + taskList = append(taskList[:index+1], append([]*Task{newTask}, taskList[index+1:]...)...) + if parent != nil { + parent.Subtasks = taskList + } else { + b.Tasks = taskList + } + + return newTask, parent, nil +} + +func (t *Task) toEvent(startTime time.Time, namePrefix string) Event { + return Event{ + Start: *NewTimestampFromGotime(startTime), + End: *NewTimestampFromGotime( + func() time.Time { + return startTime.Add(t.getDurationNormalized()) + }(), + ), + Name: namePrefix + t.Name, + Cat: t.Category, + } +} + +// ToEvent convernts a task (including potential subtasks) to the corresponding +// set of events (subtasks becoming events during the main event, recursively). +func (t *Task) ToEvent(startTime time.Time, namePrefix string) []*Event { + e := t.toEvent(startTime, namePrefix) + result := []*Event{&e} + subtaskStartTime := startTime + for _, subtask := range t.Subtasks { + subtaskEvents := subtask.ToEvent(subtaskStartTime, namePrefix+t.Name+": ") + result = append(result, subtaskEvents...) + subtaskStartTime = subtaskStartTime.Add(subtask.getDurationNormalized()) + } + return result +} + +func sumDurationNormalized(tasks []*Task) time.Duration { + sum := time.Duration(0) + for _, t := range tasks { + sum = sum + t.getDurationNormalized() + } + return sum +} + +func (t *Task) getDurationNormalized() time.Duration { + if t.Duration == nil { + subtaskDur := sumDurationNormalized(t.Subtasks) + if subtaskDur == 0 { + return 1 * time.Hour + } else { + return subtaskDur + } + } else { + return *t.Duration + } +} + +// NOTE: technically this following code is unused, it may be useful at some point though + +type ByDeadline []*Task + +func (a ByDeadline) Len() int { return len(a) } +func (a ByDeadline) Swap(i, j int) { a[i], a[j] = a[j], a[i] } + +func (a ByDeadline) Less(i, j int) bool { + switch { + + case a[i].Deadline == nil && a[j].Deadline == nil: // neither deadlines + if a[i].Category.Priority != a[i].Category.Priority { + return a[i].Category.Priority > a[j].Category.Priority + } + return true + + case a[i].Deadline == nil && a[j].Deadline != nil: // only second deadline + return false + + case a[i].Deadline != nil && a[j].Deadline == nil: // only first deadline + return true + + case a[i].Deadline != nil && a[j].Deadline != nil: // both deadlines + return a[i].Deadline.Before(*a[j].Deadline) + + } + + log.Fatal().Msg("this is impossible to reach, how did you do it?") + return true +} diff --git a/internal/model/timestamp.go b/internal/model/timestamp.go index 2c5c8b93..1e5d9b24 100644 --- a/internal/model/timestamp.go +++ b/internal/model/timestamp.go @@ -80,6 +80,7 @@ func (a Timestamp) IsAfter(b Timestamp) bool { } } +// TODO: migrate to time.Duration-based func (t Timestamp) Snap(minutesModulus int) Timestamp { minutes := t.toMinutes() diff --git a/internal/filehandling/filehandler.go b/internal/storage/old_day_file_handler.go similarity index 97% rename from internal/filehandling/filehandler.go rename to internal/storage/old_day_file_handler.go index e8a21aee..338fdcc2 100644 --- a/internal/filehandling/filehandler.go +++ b/internal/storage/old_day_file_handler.go @@ -1,4 +1,4 @@ -package filehandling +package storage import ( "bufio" diff --git a/internal/tui/screen_handler.go b/internal/tui/screen_handler.go index 38dba271..fb3e71aa 100644 --- a/internal/tui/screen_handler.go +++ b/internal/tui/screen_handler.go @@ -59,10 +59,10 @@ func (s *ScreenHandler) NeedsSync() { s.needsSync = true } -// GetScreenDimensions returns the current dimensions of the underlying screen. -func (s *ScreenHandler) GetScreenDimensions() (int, int) { - s.screen.SetStyle(tcell.StyleDefault) - return s.screen.Size() +// Dimensions returns the current dimensions of the underlying screen. +func (s *ScreenHandler) Dimensions() (x, y, w, h int) { + w, h = s.screen.Size() + return 0, 0, w, h } // ShowCursor sets the position of the text cursor. @@ -94,15 +94,17 @@ func (s *ScreenHandler) Show() { } // DrawText draws given text, within given dimensions in the given style. -func (s *ScreenHandler) DrawText(x, y, w, h int, style tcell.Style, text string) { +func (s *ScreenHandler) DrawText(x, y, w, h int, style styling.DrawStyling, text string) { if w <= 0 || h <= 0 { return } + tcellStyle := style.AsTcell() + col := x row := y for _, r := range text { - s.screen.SetContent(col, row, r, nil, style) + s.screen.SetContent(col, row, r, nil, tcellStyle) col++ if col >= x+w { row++ @@ -116,87 +118,15 @@ func (s *ScreenHandler) DrawText(x, y, w, h int, style tcell.Style, text string) // DrawBox draws a box of the given dimensions in the given style's background // color. Note that this overwrites contents within the dimensions. -func (s *ScreenHandler) DrawBox(style tcell.Style, x, y, w, h int) { +func (s *ScreenHandler) DrawBox(x, y, w, h int, style styling.DrawStyling) { + tcellStyle := style.AsTcell() for row := y; row < y+h; row++ { for col := x; col < x+w; col++ { - s.screen.SetContent(col, row, ' ', nil, style) + s.screen.SetContent(col, row, ' ', nil, tcellStyle) } } } -// ConstrainedRenderer is a constrained renderer for a TUI. -// It only allows rendering using the underlying screen handler within the set -// dimension constraint. -// -// Non-conforming rendering requests are corrected to be within the bounds. -type ConstrainedRenderer struct { - screenHandler *ScreenHandler - - constraint func() (x, y, w, h int) -} - -func NewConstrainedRenderer( - screenHandler *ScreenHandler, - constraint func() (x, y, w, h int), -) *ConstrainedRenderer { - return &ConstrainedRenderer{ - screenHandler: screenHandler, - constraint: constraint, - } -} - -// DrawText draws the given text, within the given dimensions, constrained by -// the set constraint, in the given style. -// TODO: should probably change the drawn text manually. -func (r *ConstrainedRenderer) DrawText(x, y, w, h int, styling styling.DrawStyling, text string) { - cx, cy, cw, ch := r.constrain(x, y, w, h) - - r.screenHandler.DrawText(cx, cy, cw, ch, styling.AsTcell(), text) -} - -// DrawBox draws a box of the given dimensions, constrained by the set -// constraint, in the given style. -func (r *ConstrainedRenderer) DrawBox(x, y, w, h int, styling styling.DrawStyling) { - cx, cy, cw, ch := r.constrain(x, y, w, h) - r.screenHandler.DrawBox(styling.AsTcell(), cx, cy, cw, ch) -} - -func (r *ConstrainedRenderer) constrain(rawX, rawY, rawW, rawH int) (constrainedX, constrainedY, constrainedW, constrainedH int) { - xConstraint, yConstraint, wConstraint, hConstraint := r.constraint() - - // ensure x, y in bounds, shorten width,height if x,y needed to be moved - if rawX < xConstraint { - constrainedX = xConstraint - rawW -= xConstraint - rawX - } else { - constrainedX = rawX - } - if rawY < yConstraint { - constrainedY = yConstraint - rawH -= yConstraint - rawY - } else { - constrainedY = rawY - } - - xRelativeOffset := constrainedX - xConstraint - maxAllowableW := wConstraint - xRelativeOffset - yRelativeOffset := constrainedY - yConstraint - maxAllowableH := hConstraint - yRelativeOffset - - if rawW > maxAllowableW { - constrainedW = maxAllowableW - } else { - constrainedW = rawW - } - if rawH > maxAllowableH { - constrainedH = maxAllowableH - } else { - constrainedH = rawH - } - - return constrainedX, constrainedY, constrainedW, constrainedH -} - // EventPollable only allows access to PollEvent of a tcell.Screen. type EventPollable interface { PollEvent() tcell.Event diff --git a/internal/ui/backlog_view_params.go b/internal/ui/backlog_view_params.go new file mode 100644 index 00000000..f95f43be --- /dev/null +++ b/internal/ui/backlog_view_params.go @@ -0,0 +1,102 @@ +package ui + +import ( + "fmt" + "sync" + "time" + + "github.com/rs/zerolog/log" +) + +// BacklogViewParams represents the zoom and scroll of a timeline in the UI. +type BacklogViewParams struct { + mtx sync.RWMutex + + // NRowsPerHour is the number of rows in the UI that represent an hour in the + // timeline. + NRowsPerHour *int + // ScrollOffset is the offset in rows by which the UI is scrolled. + // (An unscrolled UI would have 00:00 at the very top.) + ScrollOffset int +} + +// MinutesPerRow returns the number of minutes a single row represents. +func (p *BacklogViewParams) DurationOfHeight(rows int) time.Duration { + p.mtx.RLock() + defer p.mtx.RUnlock() + + return time.Duration(int64(60/float64(*p.NRowsPerHour))) * time.Minute +} + +func (p *BacklogViewParams) HeightOfDuration(dur time.Duration) float64 { + p.mtx.RLock() + defer p.mtx.RUnlock() + + return float64(*p.NRowsPerHour) * (float64(dur) / float64(time.Hour)) +} + +func (p *BacklogViewParams) GetScrollOffset() int { + p.mtx.RLock() + defer p.mtx.RUnlock() + + return p.ScrollOffset +} + +func (p *BacklogViewParams) GetZoomPercentage() float64 { + p.mtx.RLock() + defer p.mtx.RUnlock() + + switch *p.NRowsPerHour { + case 6: + return 100 + case 3: + return 50 + case 12: + return 200 + default: + log.Fatal().Int("NRowsPerHour", *p.NRowsPerHour).Msg("unexpected NRowsPerHour") + return 0 + } +} + +func (p *BacklogViewParams) SetZoom(percentage float64) error { + p.mtx.Lock() + defer p.mtx.Unlock() + + switch percentage { + case 50: + *p.NRowsPerHour = 3 + case 100: + *p.NRowsPerHour = 6 + case 200: + *p.NRowsPerHour = 12 + default: + return fmt.Errorf("invalid absolute zoom percentage %f for this view-param", percentage) + } + return nil +} + +func (p *BacklogViewParams) ChangeZoomBy(percentage float64) error { + p.mtx.Lock() + defer p.mtx.Unlock() + + switch { + case percentage == 50 && (*p.NRowsPerHour == 12 || *p.NRowsPerHour == 6): + *p.NRowsPerHour /= 2 + return nil + case percentage == 200 && (*p.NRowsPerHour == 6 || *p.NRowsPerHour == 3): + *p.NRowsPerHour *= 2 + return nil + case percentage == 100: + return nil + default: + return fmt.Errorf("invalid zoom change percentage %f for this view-param", percentage) + } +} + +func (p *BacklogViewParams) SetScrollOffset(offset int) { + p.mtx.Lock() + defer p.mtx.Unlock() + + p.ScrollOffset = offset +} diff --git a/internal/ui/panes/base.go b/internal/ui/base_pane.go similarity index 55% rename from internal/ui/panes/base.go rename to internal/ui/base_pane.go index 21fdf9c6..5c5b6309 100644 --- a/internal/ui/panes/base.go +++ b/internal/ui/base_pane.go @@ -1,24 +1,23 @@ -package panes +package ui import ( "github.com/ja-he/dayplan/internal/input" - "github.com/ja-he/dayplan/internal/ui" ) -// Base is the base data necessary for a UI pane and provides a base +// BasePane is the base data necessary for a UI pane and provides a base // implementation using them. // // Note that constructing this value that you need to assign the ID. -type Base struct { - ID ui.PaneID - Parent ui.PaneQuerier +type BasePane struct { + ID PaneID + Parent PaneQuerier InputProcessor input.ModalInputProcessor Visible func() bool } // Identify returns the panes ID. -func (p *Base) Identify() ui.PaneID { - if p.ID == ui.NonePaneID { +func (p *BasePane) Identify() PaneID { + if p.ID == NonePaneID { // NOTE(ja-he): generally, the none-value is OK; put this here to catch errors early panic("pane has not been assigned an ID") } @@ -26,7 +25,7 @@ func (p *Base) Identify() ui.PaneID { } // SetParent sets the pane's parent. -func (p *Base) SetParent(parent ui.PaneQuerier) { p.Parent = parent } +func (p *BasePane) SetParent(parent PaneQuerier) { p.Parent = parent } // IsVisible indicates whether the pane is visible. -func (p *Base) IsVisible() bool { return p.Visible == nil || p.Visible() } +func (p *BasePane) IsVisible() bool { return p.Visible == nil || p.Visible() } diff --git a/internal/ui/constrained_renderer.go b/internal/ui/constrained_renderer.go new file mode 100644 index 00000000..1c7a72b2 --- /dev/null +++ b/internal/ui/constrained_renderer.go @@ -0,0 +1,80 @@ +package ui + +import "github.com/ja-he/dayplan/internal/styling" + +// CR is a constrained renderer for a TUI. +// It only allows rendering using the underlying screen handler within the set +// dimension constraint. +// +// Non-conforming rendering requests are corrected to be within the bounds. +type CR struct { + renderer Renderer + + constraint func() (x, y, w, h int) +} + +func NewConstrainedRenderer( + renderer ConstrainedRenderer, + constraint func() (x, y, w, h int), +) *CR { + return &CR{ + renderer: renderer, + constraint: constraint, + } +} + +func (r *CR) Dimensions() (x, y, w, h int) { + return r.constraint() +} + +// DrawText draws the given text, within the given dimensions, constrained by +// the set constraint, in the given style. +// TODO: should probably change the drawn text manually. +func (r *CR) DrawText(x, y, w, h int, styling styling.DrawStyling, text string) { + cx, cy, cw, ch := r.constrain(x, y, w, h) + + r.renderer.DrawText(cx, cy, cw, ch, styling, text) +} + +// DrawBox draws a box of the given dimensions, constrained by the set +// constraint, in the given style. +func (r *CR) DrawBox(x, y, w, h int, sty styling.DrawStyling) { + cx, cy, cw, ch := r.constrain(x, y, w, h) + r.renderer.DrawBox(cx, cy, cw, ch, sty) +} + +func (r *CR) constrain(rawX, rawY, rawW, rawH int) (constrainedX, constrainedY, constrainedW, constrainedH int) { + xConstraint, yConstraint, wConstraint, hConstraint := r.constraint() + + // ensure x, y in bounds, shorten width,height if x,y needed to be moved + if rawX < xConstraint { + constrainedX = xConstraint + rawW -= xConstraint - rawX + } else { + constrainedX = rawX + } + if rawY < yConstraint { + constrainedY = yConstraint + rawH -= yConstraint - rawY + } else { + constrainedY = rawY + } + + xRelativeOffset := constrainedX - xConstraint + maxAllowableW := wConstraint - xRelativeOffset + yRelativeOffset := constrainedY - yConstraint + maxAllowableH := hConstraint - yRelativeOffset + + if rawW > maxAllowableW { + constrainedW = maxAllowableW + } else { + constrainedW = rawW + } + if rawH > maxAllowableH { + constrainedH = maxAllowableH + } else { + constrainedH = rawH + } + + return constrainedX, constrainedY, constrainedW, constrainedH +} diff --git a/internal/ui/panes/leaf.go b/internal/ui/leaf_pane.go similarity index 69% rename from internal/ui/panes/leaf.go rename to internal/ui/leaf_pane.go index 657b5682..fe8926a1 100644 --- a/internal/ui/panes/leaf.go +++ b/internal/ui/leaf_pane.go @@ -1,46 +1,43 @@ -package panes +package ui import ( "github.com/ja-he/dayplan/internal/input" "github.com/ja-he/dayplan/internal/styling" - "github.com/ja-he/dayplan/internal/ui" ) -// Leaf is a simple set of data and implementation of a "leaf pane", i.E. a +// LeafPane is a simple set of data and implementation of a "leaf pane", i.E. a // pane that does not have subpanes but instead makes actual draw calls. -type Leaf struct { - Base - renderer ui.ConstrainedRenderer - dimensions func() (x, y, w, h int) - stylesheet styling.Stylesheet +type LeafPane struct { + BasePane + Renderer ConstrainedRenderer + Dims func() (x, y, w, h int) + Stylesheet styling.Stylesheet } -// Dimensions gives the dimensions (x-axis offset, y-axis offset, width, -// height) for this pane. -func (p *Leaf) Dimensions() (x, y, w, h int) { - return p.dimensions() +func (p *LeafPane) Dimensions() (x, y, w, h int) { + return p.Dims() } // Draw panics. It MUST be overridden if it is to be called. -func (p *Leaf) Draw() { +func (p *LeafPane) Draw() { // TODO: draw fill with warning message panic("unimplemented draw") } // Undraw does nothing. Override this, if necessary. -func (p *Leaf) Undraw() {} +func (p *LeafPane) Undraw() {} // HasFocus returns whether the pane has focus. -func (p *Leaf) HasFocus() bool { +func (p *LeafPane) HasFocus() bool { return p.Parent != nil && p.Parent.HasFocus() && p.Parent.Focusses() == p.Identify() } // Focusses returns the "none pane", as a leaf does not focus another pane. -func (p *Leaf) Focusses() ui.PaneID { return ui.NonePaneID } +func (p *LeafPane) Focusses() PaneID { return NonePaneID } // CapturesInput returns whether this processor "captures" input, i.E. whether // it ought to take priority in processing over other processors. -func (p *Leaf) CapturesInput() bool { +func (p *LeafPane) CapturesInput() bool { return p.InputProcessor != nil && p.InputProcessor.CapturesInput() } @@ -48,14 +45,14 @@ func (p *Leaf) CapturesInput() bool { // Returns whether the provided input "applied", i.E. the processor performed // an action based on the input. // Defers to the panes' input processor. -func (p *Leaf) ProcessInput(key input.Key) bool { +func (p *LeafPane) ProcessInput(key input.Key) bool { return p.InputProcessor != nil && p.InputProcessor.ProcessInput(key) } // ApplyModalOverlay applies an overlay to this processor. // It returns the processors index, by which in the future, all overlays down // to and including this overlay can be removed -func (p *Leaf) ApplyModalOverlay(overlay input.SimpleInputProcessor) (index uint) { +func (p *LeafPane) ApplyModalOverlay(overlay input.SimpleInputProcessor) (index uint) { if p.InputProcessor == nil { panic("ApplyModalOverlay on nil InputProcessor") } @@ -63,7 +60,7 @@ func (p *Leaf) ApplyModalOverlay(overlay input.SimpleInputProcessor) (index uint } // PopModalOverlay removes the topmost overlay from this processor. -func (p *Leaf) PopModalOverlay() error { +func (p *LeafPane) PopModalOverlay() error { if p.InputProcessor == nil { panic("PopModalOverlay on nil InputProcessor") } @@ -72,7 +69,7 @@ func (p *Leaf) PopModalOverlay() error { // PopModalOverlays pops all overlays down to and including the one at the // specified index. -func (p *Leaf) PopModalOverlays(index uint) { +func (p *LeafPane) PopModalOverlays(index uint) { if p.InputProcessor == nil { panic("PopModalOverlays on nil InputProcessor") } @@ -80,7 +77,7 @@ func (p *Leaf) PopModalOverlays(index uint) { } // GetHelp returns the input help map for this processor. -func (p *Leaf) GetHelp() input.Help { +func (p *LeafPane) GetHelp() input.Help { if p.InputProcessor == nil { return input.Help{} } @@ -89,8 +86,8 @@ func (p *Leaf) GetHelp() input.Help { // FocusPrev does nothing, as this implements a leaf, which does not focus // anything. -func (p *Leaf) FocusPrev() {} +func (p *LeafPane) FocusPrev() {} // FocusNext does nothing, as this implements a leaf, which does not focus // anything. -func (p *Leaf) FocusNext() {} +func (p *LeafPane) FocusNext() {} diff --git a/internal/ui/panes/backlog_pane.go b/internal/ui/panes/backlog_pane.go new file mode 100644 index 00000000..4034378f --- /dev/null +++ b/internal/ui/panes/backlog_pane.go @@ -0,0 +1,237 @@ +package panes + +import ( + "strings" + "sync" + + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/model" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" + "github.com/ja-he/dayplan/internal/util" + "github.com/rs/zerolog/log" +) + +// BacklogPane shows a tasks backlog from which tasks and prospective events can +// be selected and moved into concrete days, i.e., planned. +type BacklogPane struct { + ui.LeafPane + viewParams ui.TimeViewParams + getCurrentTask func() *model.Task + backlog *model.Backlog + categoryStyleProvider func(model.Category) (styling.DrawStyling, error) + + uiBoundsMtx sync.RWMutex + uiBounds map[*model.Task]taskUIYBounds +} + +// Dimensions gives the dimensions (x-axis offset, y-axis offset, width, +// height) for this pane. +// GetPositionInfo returns information on a requested position in this pane. +func (p *BacklogPane) Dimensions() (x, y, w, h int) { + return p.Dims() +} + +// Draw draws this pane. +func (p *BacklogPane) Draw() { + p.uiBoundsMtx.Lock() + defer p.uiBoundsMtx.Unlock() + + if !p.IsVisible() { + return + } + + x, y, w, h := p.Dims() + + // background + bgStyle := p.Stylesheet.Normal + if p.HasFocus() { + bgStyle = p.Stylesheet.NormalEmphasized + } + func() { + p.Renderer.DrawBox(x, y, w, h, bgStyle) + }() + + // draws task, taking into account view params, returns y space used + var drawTask func(xBase, yOffset, wBase int, t *model.Task, depth int, emphasize bool) (int, []func()) + drawTask = func(xBase, yOffset, wBase int, t *model.Task, depth int, emphasize bool) (int, []func()) { + drawThis := []func(){} + + var h int + if t.Duration == nil { + h = 2 * int(p.viewParams.GetZoomPercentage()/50.0) + } else { + h = int(p.viewParams.HeightOfDuration(*t.Duration)) + } + if len(t.Subtasks) > 0 { + yIter := yOffset + 1 + for i, st := range t.Subtasks { + drawnHeight, drawCalls := drawTask(xBase+1, yIter, wBase-2, st, depth+1, emphasize || p.getCurrentTask() == st) + drawThis = append(drawThis, drawCalls...) + effectiveYIncrease := drawnHeight + if i != len(t.Subtasks)-1 { + effectiveYIncrease += 1 + } + h += effectiveYIncrease + yIter += effectiveYIncrease + } + } + + style, err := p.categoryStyleProvider(t.Category) + if err != nil { + style = p.Stylesheet.CategoryFallback + } + style = style.DarkenedBG(depth * 10) + + if emphasize { + style = style.DefaultEmphasized() + } + + if emphasize { + xBase -= 1 + wBase += 2 + } + drawThis = append(drawThis, func() { + p.Renderer.DrawBox( + xBase+1, yOffset, wBase-2, h, + style, + ) + p.Renderer.DrawText( + xBase+1+1, yOffset, wBase-2-1, 1, + style.Bolded(), + util.TruncateAt(t.Name, wBase-2-1), + ) + p.Renderer.DrawText( + xBase+3, yOffset+1, wBase-2-2, 1, + style.Italicized(), + util.TruncateAt(t.Category.Name, wBase-2-2), + ) + if t.Deadline != nil { + deadline := t.Deadline.Format("2006-01-02 15:04:05") + p.Renderer.DrawText( + xBase+wBase-len(deadline)-1, yOffset+1, len(deadline), 1, + style.Bolded(), + deadline, + ) + } + p.uiBounds[t] = taskUIYBounds{yOffset, yOffset + h - 1} + }) + + return h, drawThis + } + + // draw tasks + func() { + p.backlog.Mtx.RLock() + defer p.backlog.Mtx.RUnlock() + + yIter := y - p.viewParams.GetScrollOffset() + + // draw top indicator + func() { + text := " top " + padFront := strings.Repeat("-", (w-2-len(text))/2) + padBack := strings.Repeat("-", (w-2)-(len(padFront)+len(text))) + p.Renderer.DrawText( + x+1, yIter+1, w-2, 1, + bgStyle, + padFront+text+padBack, + ) + }() + + yIter += 1 + + for _, task := range p.backlog.Tasks { + yIter += 1 + heightDrawn, drawFuncs := drawTask(x+1, yIter, w-2, task, 0, p.getCurrentTask() == task) + for i := range drawFuncs { + drawFuncs[len(drawFuncs)-1-i]() + } + yIter += heightDrawn + } + + func() { + text := " bottom " + padFront := strings.Repeat("-", (w-2-len(text))/2) + padBack := strings.Repeat("-", (w-2)-(len(padFront)+len(text))) + p.Renderer.DrawText( + x+1, yIter, w-2, 1, + bgStyle, + padFront+text+padBack, + ) + }() + }() + + // draw title last + func() { + style := p.Stylesheet.NormalEmphasized.DefaultEmphasized() + + p.Renderer.DrawBox(x, y, w, 1, style) + + titleText := "Backlog" + p.Renderer.DrawText(x+(w/2)-(len(titleText)/2), y, len(titleText), 1, style.Bolded(), titleText) + }() +} + +func (p *BacklogPane) GetTaskUIYBounds(t *model.Task) (lb, ub int) { + p.uiBoundsMtx.RLock() + defer p.uiBoundsMtx.RUnlock() + r, ok := p.uiBounds[t] + if ok { + return r.lb, r.ub + } else { + log.Warn().Interface("task", t). + Msg("backlog pane asked for position of unknown task") + return 0, 0 + } +} + +func (p *BacklogPane) GetTaskVisibilityBounds() (lb, ub int) { + _, y, _, h := p.Dims() + return y, y + h - 1 +} + +// GetPositionInfo returns information on a requested position in this pane. +func (p *BacklogPane) GetPositionInfo(x, y int) ui.PositionInfo { + return &BacklogPanePositionInfo{} +} + +// BacklogPanePositionInfo conveys information on a position in a BacklogPane. +type BacklogPanePositionInfo struct { +} + +// NewBacklogPane constructs and returns a new BacklogPane. +func NewBacklogPane( + renderer ui.ConstrainedRenderer, + dimensions func() (x, y, w, h int), + stylesheet styling.Stylesheet, + inputProcessor input.ModalInputProcessor, + viewParams ui.TimeViewParams, + getCurrentTask func() *model.Task, + backlog *model.Backlog, + categoryStyleProvider func(model.Category) (styling.DrawStyling, error), + visible func() bool, +) *BacklogPane { + p := BacklogPane{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ + ID: ui.GeneratePaneID(), + InputProcessor: inputProcessor, + Visible: visible, + }, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, + }, + viewParams: viewParams, + getCurrentTask: getCurrentTask, + backlog: backlog, + categoryStyleProvider: categoryStyleProvider, + uiBounds: make(map[*model.Task]taskUIYBounds), + } + return &p +} + +type taskUIYBounds struct { + lb, ub int +} diff --git a/internal/ui/panes/composite.go b/internal/ui/panes/composite.go index d095ffad..3c71f663 100644 --- a/internal/ui/panes/composite.go +++ b/internal/ui/panes/composite.go @@ -12,7 +12,7 @@ import ( // Composite is a generic wrapper pane whithout any rendering logic of its // own. type Composite struct { - Base + ui.BasePane drawables []ui.Pane focussables []ui.Pane @@ -61,7 +61,7 @@ func (p *Composite) Dimensions() (x, y, w, h int) { // GetPositionInfo returns information on a requested position in this pane. func (p *Composite) GetPositionInfo(x, y int) ui.PositionInfo { for _, pane := range p.drawables { - if util.NewRect(pane.Dimensions()).Contains(x, y) { + if pane.IsVisible() && util.NewRect(pane.Dimensions()).Contains(x, y) { return pane.GetPositionInfo(x, y) } } @@ -73,10 +73,12 @@ func (p *Composite) GetPositionInfo(x, y int) ui.PositionInfo { func (p *Composite) FocusNext() { for i := range p.focussables { if p.FocussedPane == p.focussables[i] { - if i < len(p.focussables)-1 { - p.FocussedPane = p.focussables[i+1] + for j := i + 1; j < len(p.focussables); j++ { + if p.focussables[j].IsVisible() { + p.FocussedPane = p.focussables[j] + return + } } - return } } } @@ -85,10 +87,22 @@ func (p *Composite) FocusNext() { func (p *Composite) FocusPrev() { for i := range p.focussables { if p.FocussedPane == p.focussables[i] { - if i > 0 { - p.FocussedPane = p.focussables[i-1] + for j := i - 1; j >= 0; j-- { + if p.focussables[j].IsVisible() { + p.FocussedPane = p.focussables[j] + return + } + } + } + } +} + +func (p *Composite) EnsureFocusIsOnVisible() { + if !p.FocussedPane.IsVisible() { + for i := range p.focussables { + if p.focussables[i].IsVisible() { + p.FocussedPane = p.focussables[i] } - return } } } @@ -169,7 +183,7 @@ func NewWrapperPane( p := &Composite{ focussables: focussables, drawables: drawables, - Base: Base{ + BasePane: ui.BasePane{ InputProcessor: inputProcessor, ID: ui.GeneratePaneID(), }, diff --git a/internal/ui/panes/composite_editor_ui_pane.go b/internal/ui/panes/composite_editor_ui_pane.go new file mode 100644 index 00000000..fa065b19 --- /dev/null +++ b/internal/ui/panes/composite_editor_ui_pane.go @@ -0,0 +1,135 @@ +package panes + +import ( + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" +) + +// CompositeEditorPane visualizes a composite editor. +type CompositeEditorPane struct { + ui.LeafPane + + getFocussedIndex func() int + isInField func() bool + + subpanes []ui.Pane + + log zerolog.Logger +} + +// Draw draws the editor popup. +func (p *CompositeEditorPane) Draw() { + if p.IsVisible() { + x, y, w, h := p.Dims() + + // draw background + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.Editor) + + // draw all subpanes + for _, subpane := range p.subpanes { + subpane.Draw() + } + + } +} + +// Undraw ensures that the cursor is hidden. +func (p *CompositeEditorPane) Undraw() { + for _, subpane := range p.subpanes { + subpane.Undraw() + } +} + +// ProcessInput attempts to process the provided input. +// Returns whether the provided input "applied", i.E. the processor performed +// an action based on the input. +// Defers to the panes' input processor or its focussed subpanes. +func (p *CompositeEditorPane) ProcessInput(key input.Key) bool { + if p.InputProcessor != nil && p.InputProcessor.CapturesInput() { + if p.isInField() { + p.log.Warn().Msgf("comp: somehow, comosite editor is capturing input despite being in field; likely logic error") + } + return p.InputProcessor.ProcessInput(key) + } + + if p.isInField() { + focussedIndex := p.getFocussedIndex() + if focussedIndex < 0 || focussedIndex >= len(p.subpanes) { + p.log.Error().Msgf("comp: somehow, focussed index for composite editor is out of bounds; %d < 0 || %d >= %d", focussedIndex, focussedIndex, len(p.subpanes)) + return false + } + processedBySubpane := p.subpanes[focussedIndex].ProcessInput(key) + if processedBySubpane { + return true + } + p.log.Warn().Msgf("comp: input '%s' was not processed by active subeditor pane; will not be processed", key.ToDebugString()) + return false + } + + if p.InputProcessor == nil { + p.log.Warn().Msg("comp: input processor is nil; will not process input") + return false + } + + p.log.Trace().Msgf("comp: processing input '%s' self", key.ToDebugString()) + return p.InputProcessor.ProcessInput(key) +} + +// GetPositionInfo returns information on a requested position in this pane (nil, for now). +func (p *CompositeEditorPane) GetPositionInfo(_, _ int) ui.PositionInfo { return nil } + +// NewCompositeEditorPane creates a new CompositeEditorPane. +func NewCompositeEditorPane( + renderer ui.ConstrainedRenderer, + visible func() bool, + inputProcessor input.ModalInputProcessor, + stylesheet styling.Stylesheet, + subEditors []ui.Pane, + getFocussedIndex func() int, + isInField func() bool, +) *CompositeEditorPane { + return &CompositeEditorPane{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ + ID: ui.GeneratePaneID(), + InputProcessor: inputProcessor, + Visible: visible, + }, + Renderer: renderer, + Dims: renderer.Dimensions, + Stylesheet: stylesheet, + }, + subpanes: subEditors, + getFocussedIndex: getFocussedIndex, + isInField: isInField, + log: log.With().Str("source", "composite-pane").Logger(), + } +} + +// GetHelp returns the input help map for this composite pane. +func (p *CompositeEditorPane) GetHelp() input.Help { + ownHelp := func() input.Help { + if p.InputProcessor == nil { + return input.Help{} + } + return p.InputProcessor.GetHelp() + }() + activeFieldHelp := func() input.Help { + if p.isInField() { + return p.subpanes[p.getFocussedIndex()].GetHelp() + } + return input.Help{} + }() + result := input.Help{} + for k, v := range ownHelp { + result[k] = v + } + for k, v := range activeFieldHelp { + result[k] = v + } + return result +} diff --git a/internal/ui/panes/editor_pane.go b/internal/ui/panes/event_editor_pane.go similarity index 79% rename from internal/ui/panes/editor_pane.go rename to internal/ui/panes/event_editor_pane.go index 115b424b..50704a38 100644 --- a/internal/ui/panes/editor_pane.go +++ b/internal/ui/panes/event_editor_pane.go @@ -6,9 +6,9 @@ import ( "github.com/ja-he/dayplan/internal/ui" ) -// EditorPane visualizes the detailed editing of an event. -type EditorPane struct { - Leaf +// EventEditorPane visualizes the detailed editing of an event. +type EventEditorPane struct { + ui.LeafPane renderer ui.ConstrainedRenderer cursorController ui.TextCursorController @@ -22,19 +22,21 @@ type EditorPane struct { } // Undraw ensures that the cursor is hidden. -func (p *EditorPane) Undraw() { p.cursorController.HideCursor() } +func (p *EventEditorPane) Undraw() { + p.cursorController.HideCursor() +} // Dimensions gives the dimensions (x-axis offset, y-axis offset, width, // height) for this pane. -func (p *EditorPane) Dimensions() (x, y, w, h int) { +func (p *EventEditorPane) Dimensions() (x, y, w, h int) { return p.dimensions() } // GetPositionInfo returns information on a requested position in this pane. -func (p *EditorPane) GetPositionInfo(x, y int) ui.PositionInfo { return nil } +func (p *EventEditorPane) GetPositionInfo(x, y int) ui.PositionInfo { return nil } // Draw draws the editor popup. -func (p *EditorPane) Draw() { +func (p *EventEditorPane) Draw() { if p.IsVisible() { x, y, w, h := p.Dimensions() @@ -60,8 +62,8 @@ func (p *EditorPane) Draw() { } } -// NewEditorPane constructs and returns a new EditorPane. -func NewEditorPane( +// NewEventEditorPane constructs and returns a new EventEditorPane. +func NewEventEditorPane( renderer ui.ConstrainedRenderer, cursorController ui.TextCursorController, dimensions func() (x, y, w, h int), @@ -71,10 +73,10 @@ func NewEditorPane( getMode func() input.TextEditMode, cursorPos func() int, inputProcessor input.ModalInputProcessor, -) *EditorPane { - return &EditorPane{ - Leaf: Leaf{ - Base: Base{ +) *EventEditorPane { + return &EventEditorPane{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), InputProcessor: inputProcessor, Visible: condition, diff --git a/internal/ui/panes/events_pane.go b/internal/ui/panes/events_pane.go index 3ae86c91..68f8229f 100644 --- a/internal/ui/panes/events_pane.go +++ b/internal/ui/panes/events_pane.go @@ -4,12 +4,13 @@ import ( "fmt" "math" + "github.com/rs/zerolog/log" + "github.com/ja-he/dayplan/internal/input" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/ui" "github.com/ja-he/dayplan/internal/util" - "github.com/rs/zerolog/log" ) // An EventsPane displays a single days events. @@ -18,13 +19,13 @@ import ( // hide some details (e.g., for showing events as part of multiple EventPanes in // in the month view. type EventsPane struct { - Leaf + ui.LeafPane day func() *model.Day styleForCategory func(model.Category) (styling.DrawStyling, error) - viewParams *ui.ViewParams + viewParams ui.TimespanViewParams cursor *ui.MouseCursorPos pad int @@ -43,31 +44,24 @@ type EventsPane struct { // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *EventsPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // GetPositionInfo returns information on a requested position in this pane. // Importantly, when there is an event at the position, it will inform of that // in detail. func (p *EventsPane) GetPositionInfo(x, y int) ui.PositionInfo { - return ui.NewPositionInfo( - ui.EventsPaneType, - nil, - nil, - nil, - nil, - p.getEventForPos(x, y), - ) + return p.getEventForPos(x, y) } // Draw draws this pane. func (p *EventsPane) Draw() { x, y, w, h := p.Dimensions() - style := p.stylesheet.Normal + style := p.Stylesheet.Normal if p.HasFocus() { - style = p.stylesheet.NormalEmphasized + style = p.Stylesheet.NormalEmphasized } - p.renderer.DrawBox(x, y, w, h, style) + p.Renderer.DrawBox(x, y, w, h, style) day := p.day() @@ -81,7 +75,7 @@ func (p *EventsPane) Draw() { style, err := p.styleForCategory(e.Cat) if err != nil { log.Error().Err(err).Str("category-name", e.Cat.Name).Msg("an error occurred getting category style") - style = p.stylesheet.CategoryFallback + style = p.Stylesheet.CategoryFallback } if !p.isCurrentDay() { style = style.DefaultDimmed() @@ -95,7 +89,7 @@ func (p *EventsPane) Draw() { } else { timestampWidth = 0 } - var hovered ui.EventsPanePositionInfo + var hovered *ui.EventsPanePositionInfo if p.mouseMode() { hovered = p.getEventForPos(p.cursor.X, p.cursor.Y) } @@ -111,10 +105,10 @@ func (p *EventsPane) Draw() { namePadding := 1 nameWidth := pos.W - (2 * namePadding) - timestampWidth - if p.mouseMode() && hovered != nil && hovered.Event() == e && hovered.EventBoxPart() != ui.EventBoxNowhere { + if p.mouseMode() && hovered != nil && hovered.Event == e && hovered.EventBoxPart != ui.EventBoxNowhere { selectionStyling := style.DefaultEmphasized() - switch hovered.EventBoxPart() { + switch hovered.EventBoxPart { case ui.EventBoxBottomRight: bottomStyling = selectionStyling.Bolded() @@ -128,26 +122,26 @@ func (p *EventsPane) Draw() { nameStyling = selectionStyling.Bolded() default: - panic(fmt.Sprint("don't know this hover state:", hovered.EventBoxPart().ToString())) + panic(fmt.Sprint("don't know this hover state:", hovered.EventBoxPart.ToString())) } } var topTimestampStyling = bodyStyling.NormalizeFromBG(0.4) var botTimestampStyling = bottomStyling.NormalizeFromBG(0.4) - p.renderer.DrawBox(pos.X, pos.Y, pos.W, pos.H, bodyStyling) + p.Renderer.DrawBox(pos.X, pos.Y, pos.W, pos.H, bodyStyling) if p.drawTimestamps { - p.renderer.DrawText(pos.X+pos.W-5, pos.Y, 5, 1, topTimestampStyling, e.Start.ToString()) + p.Renderer.DrawText(pos.X+pos.W-5, pos.Y, 5, 1, topTimestampStyling, e.Start.ToString()) } - p.renderer.DrawBox(pos.X, pos.Y+pos.H-1, pos.W, 1, bottomStyling) + p.Renderer.DrawBox(pos.X, pos.Y+pos.H-1, pos.W, 1, bottomStyling) if p.drawTimestamps { - p.renderer.DrawText(pos.X+pos.W-5, pos.Y+pos.H-1, 5, 1, botTimestampStyling, e.End.ToString()) + p.Renderer.DrawText(pos.X+pos.W-5, pos.Y+pos.H-1, 5, 1, botTimestampStyling, e.End.ToString()) } if p.drawNames { - p.renderer.DrawText(pos.X+1, pos.Y, nameWidth, 1, nameStyling, util.TruncateAt(e.Name, nameWidth)) + p.Renderer.DrawText(pos.X+1, pos.Y, nameWidth, 1, nameStyling, util.TruncateAt(e.Name, nameWidth)) } if p.drawCat && pos.H > 1 { var catStyling = bodyStyling.NormalizeFromBG(0.2).Unbolded().Italicized() @@ -155,13 +149,13 @@ func (p *EventsPane) Draw() { catStyling = bottomStyling.NormalizeFromBG(0.2).Unbolded().Italicized() } catWidth := pos.W - 2 - 1 - p.renderer.DrawText(pos.X+pos.W-1-catWidth, pos.Y+1, catWidth, 1, catStyling, util.TruncateAt(e.Cat.Name, catWidth)) + p.Renderer.DrawText(pos.X+pos.W-1-catWidth, pos.Y+1, catWidth, 1, catStyling, util.TruncateAt(e.Cat.Name, catWidth)) } } } -func (p *EventsPane) getEventForPos(x, y int) ui.EventsPanePositionInfo { +func (p *EventsPane) getEventForPos(x, y int) *ui.EventsPanePositionInfo { dimX, _, dimW, _ := p.Dimensions() if x >= dimX && @@ -179,41 +173,21 @@ func (p *EventsPane) getEventForPos(x, y int) ui.EventsPanePositionInfo { default: hover = ui.EventBoxInterior } - return &EventsPanePositionInfo{ - event: currentDay.Events[i], - eventBoxPart: hover, - time: p.viewParams.TimeAtY(y), + return &ui.EventsPanePositionInfo{ + Event: currentDay.Events[i], + EventBoxPart: hover, + Time: p.viewParams.TimeAtY(y), } } } } - return &EventsPanePositionInfo{ - event: nil, - eventBoxPart: ui.EventBoxNowhere, - time: p.viewParams.TimeAtY(y), + return &ui.EventsPanePositionInfo{ + Event: nil, + EventBoxPart: ui.EventBoxNowhere, + Time: p.viewParams.TimeAtY(y), } } -// EventsPanePositionInfo provides information on a position in an EventsPane, -// implementing the ui.EventsPanePositionInfo interface. -type EventsPanePositionInfo struct { - event *model.Event - eventBoxPart ui.EventBoxPart - time model.Timestamp -} - -// Event returns the ID of the event at the position, 0 if no event at -// position. -func (i *EventsPanePositionInfo) Event() *model.Event { return i.event } - -// EventBoxPart returns the part of the event box that corresponds to the -// position (which can be EventBoxNowhere, if no event at position). -func (i *EventsPanePositionInfo) EventBoxPart() ui.EventBoxPart { return i.eventBoxPart } - -// Time returns the time that corresponds to the position (specifically the -// y-value of the position). -func (i *EventsPanePositionInfo) Time() model.Timestamp { return i.time } - func (p *EventsPane) computeRects(day *model.Day, offsetX, offsetY, width, height int) map[*model.Event]util.Rect { activeStack := make([]*model.Event, 0) positions := make(map[*model.Event]util.Rect) @@ -260,7 +234,7 @@ func NewEventsPane( inputProcessor input.ModalInputProcessor, day func() *model.Day, styleForCategory func(model.Category) (styling.DrawStyling, error), - viewParams *ui.ViewParams, + viewParams ui.TimespanViewParams, cursor *ui.MouseCursorPos, pad int, drawTimestamps bool, @@ -271,14 +245,14 @@ func NewEventsPane( mouseMode func() bool, ) *EventsPane { return &EventsPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), InputProcessor: inputProcessor, }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, day: day, styleForCategory: styleForCategory, diff --git a/internal/ui/panes/help_pane.go b/internal/ui/panes/help_pane.go index dc72abe0..f269fbc0 100644 --- a/internal/ui/panes/help_pane.go +++ b/internal/ui/panes/help_pane.go @@ -10,7 +10,7 @@ import ( // HelpPane conditionally be hidden or display a set of keyboad shortcuts. type HelpPane struct { - Leaf + ui.LeafPane Content input.Help } @@ -19,7 +19,7 @@ type HelpPane struct { // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *HelpPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // GetPositionInfo returns information on a requested position in this pane. @@ -30,7 +30,7 @@ func (p *HelpPane) Draw() { if p.IsVisible() { x, y, w, h := p.Dimensions() - p.renderer.DrawBox(x, y, w, h, p.stylesheet.Help) + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.Help) keysDrawn := 0 const border = 1 @@ -40,8 +40,8 @@ func (p *HelpPane) Draw() { descriptionOffset := keyOffset + maxKeyWidth + pad drawMapping := func(keys, description string) { - p.renderer.DrawText(keyOffset+maxKeyWidth-len([]rune(keys)), y+border+keysDrawn, len([]rune(keys)), 1, p.stylesheet.Help.DefaultEmphasized().Bolded(), keys) - p.renderer.DrawText(descriptionOffset, y+border+keysDrawn, w, h, p.stylesheet.Help.Italicized(), description) + p.Renderer.DrawText(keyOffset+maxKeyWidth-len([]rune(keys)), y+border+keysDrawn, len([]rune(keys)), 1, p.Stylesheet.Help.DefaultEmphasized().Bolded(), keys) + p.Renderer.DrawText(descriptionOffset, y+border+keysDrawn, w, h, p.Stylesheet.Help.Italicized(), description) keysDrawn++ } @@ -80,14 +80,14 @@ func NewHelpPane( inputProcessor input.ModalInputProcessor, ) *HelpPane { p := &HelpPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), Visible: condition, }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, } p.InputProcessor = inputProcessor diff --git a/internal/ui/panes/log_pane.go b/internal/ui/panes/log_pane.go index 9ddfbecb..51d2cfe6 100644 --- a/internal/ui/panes/log_pane.go +++ b/internal/ui/panes/log_pane.go @@ -11,7 +11,7 @@ import ( // LogPane shows the log, with the most recent log entries at the top. type LogPane struct { - Leaf + ui.LeafPane logReader potatolog.LogReader @@ -22,7 +22,7 @@ type LogPane struct { // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *LogPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // Draw draws the time summary view over top of all previously drawn contents, @@ -33,44 +33,44 @@ func (p *LogPane) Draw() { x, y, w, h := p.Dimensions() row := 2 - p.renderer.DrawBox(x, y, w, h, p.stylesheet.LogDefault) + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.LogDefault) title := p.titleString() - p.renderer.DrawBox(x, y, w, 1, p.stylesheet.LogTitleBox) - p.renderer.DrawText(x+(w/2-len(title)/2), y, len(title), 1, p.stylesheet.LogTitleBox, title) + p.Renderer.DrawBox(x, y, w, 1, p.Stylesheet.LogTitleBox) + p.Renderer.DrawText(x+(w/2-len(title)/2), y, len(title), 1, p.Stylesheet.LogTitleBox, title) for i := len(p.logReader.Get()) - 1; i >= 0; i-- { entry := p.logReader.Get()[i] levelLen := len(" error ") extraDataIndentWidth := levelLen + 1 - p.renderer.DrawText( + p.Renderer.DrawText( x, y+row, levelLen, 1, func() styling.DrawStyling { switch entry["level"] { case "error": - return p.stylesheet.LogEntryTypeError + return p.Stylesheet.LogEntryTypeError case "warn": - return p.stylesheet.LogEntryTypeWarn + return p.Stylesheet.LogEntryTypeWarn case "info": - return p.stylesheet.LogEntryTypeInfo + return p.Stylesheet.LogEntryTypeInfo case "debug": - return p.stylesheet.LogEntryTypeDebug + return p.Stylesheet.LogEntryTypeDebug case "trace": - return p.stylesheet.LogEntryTypeTrace + return p.Stylesheet.LogEntryTypeTrace } - return p.stylesheet.LogDefault + return p.Stylesheet.LogDefault }(), util.PadCenter(entry["level"], levelLen), ) x = extraDataIndentWidth - p.renderer.DrawText(x, y+row, w, 1, p.stylesheet.LogDefault, entry["message"]) + p.Renderer.DrawText(x, y+row, w, 1, p.Stylesheet.LogDefault, entry["message"]) x += len(entry["message"]) + 1 - p.renderer.DrawText(x, y+row, w, 1, p.stylesheet.LogEntryLocation, entry["caller"]) + p.Renderer.DrawText(x, y+row, w, 1, p.Stylesheet.LogEntryLocation, entry["caller"]) x += len(entry["caller"]) + 1 timeStr := entry["time"] - p.renderer.DrawText(x, y+row, w, 1, p.stylesheet.LogEntryTime, timeStr) + p.Renderer.DrawText(x, y+row, w, 1, p.Stylesheet.LogEntryTime, timeStr) x = extraDataIndentWidth row++ @@ -84,8 +84,8 @@ func (p *LogPane) Draw() { sort.Sort(ByAlphabeticOrder(keys)) for _, k := range keys { if k != "caller" && k != "message" && k != "time" && k != "level" { - p.renderer.DrawText(x, y+row, w, 1, p.stylesheet.LogEntryTime, k) - p.renderer.DrawText(x+len(k)+2, y+row, w, 1, p.stylesheet.LogEntryLocation, entry[k]) + p.Renderer.DrawText(x, y+row, w, 1, p.Stylesheet.LogEntryTime, k) + p.Renderer.DrawText(x+len(k)+2, y+row, w, 1, p.Stylesheet.LogEntryLocation, entry[k]) row++ } } @@ -110,14 +110,14 @@ func NewLogPane( logReader potatolog.LogReader, ) *LogPane { return &LogPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ Visible: condition, ID: ui.GeneratePaneID(), }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, titleString: titleString, logReader: logReader, diff --git a/internal/ui/panes/maybe_pane.go b/internal/ui/panes/maybe_pane.go index 1b4437ce..9454a6f6 100644 --- a/internal/ui/panes/maybe_pane.go +++ b/internal/ui/panes/maybe_pane.go @@ -43,14 +43,7 @@ func (p *MaybePane) GetPositionInfo(x, y int) ui.PositionInfo { if p.condition() { return p.pane.GetPositionInfo(x, y) } - return ui.NewPositionInfo( - ui.NoPane, - nil, - nil, - nil, - nil, - nil, - ) + return nil } // SetParent iff condition. diff --git a/internal/ui/panes/perf_pane.go b/internal/ui/panes/perf_pane.go index 30b0a1d9..988fc839 100644 --- a/internal/ui/panes/perf_pane.go +++ b/internal/ui/panes/perf_pane.go @@ -13,7 +13,7 @@ import ( // PerfPane is an ephemeral pane used for showing debug info during normal // usage. type PerfPane struct { - Leaf + ui.LeafPane renderTime util.MetricsGetter eventProcessingTime util.MetricsGetter @@ -23,7 +23,7 @@ type PerfPane struct { // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *PerfPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // Draw draws this pane. @@ -37,7 +37,7 @@ func (p *PerfPane) Draw() { eventAvg := p.eventProcessingTime.Avg() eventLast := p.eventProcessingTime.GetLast() - x, y, w, h := p.dimensions() + x, y, w, h := p.Dims() lastWidth := len(" render time: ....... xs ") avgWidth := w - lastWidth @@ -63,13 +63,13 @@ func (p *PerfPane) Draw() { colorful.Hsl(hue, eventSat, ltn), ) - p.renderer.DrawBox(x, y, w, h, defaultStyle) + p.Renderer.DrawBox(x, y, w, h, defaultStyle) - p.renderer.DrawText(x, y, lastWidth, 1, renderStyle, fmt.Sprintf(" render time: % 7d µs ", renderLast)) - p.renderer.DrawText(x, y+1, lastWidth, 1, eventStyle, fmt.Sprintf(" input time: % 7d µs ", eventLast)) + p.Renderer.DrawText(x, y, lastWidth, 1, renderStyle, fmt.Sprintf(" render time: % 7d µs ", renderLast)) + p.Renderer.DrawText(x, y+1, lastWidth, 1, eventStyle, fmt.Sprintf(" input time: % 7d µs ", eventLast)) - p.renderer.DrawText(x+lastWidth, y, avgWidth, 1, defaultStyle, fmt.Sprintf(" render avg ~ % 7d µs", renderAvg)) - p.renderer.DrawText(x+lastWidth, y+1, avgWidth, 1, defaultStyle, fmt.Sprintf(" input avg ~ % 7d µs", eventAvg)) + p.Renderer.DrawText(x+lastWidth, y, avgWidth, 1, defaultStyle, fmt.Sprintf(" render avg ~ % 7d µs", renderAvg)) + p.Renderer.DrawText(x+lastWidth, y+1, avgWidth, 1, defaultStyle, fmt.Sprintf(" input avg ~ % 7d µs", eventAvg)) } // GetPositionInfo returns information on a requested position in this pane. @@ -91,13 +91,13 @@ func NewPerfPane( eventProcessingTime util.MetricsGetter, ) *PerfPane { return &PerfPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), Visible: condition, }, - renderer: renderer, - dimensions: dimensions, + Renderer: renderer, + Dims: dimensions, }, renderTime: renderTime, eventProcessingTime: eventProcessingTime, diff --git a/internal/ui/panes/root_pane.go b/internal/ui/panes/root_pane.go index 14da76b3..84eaa33c 100644 --- a/internal/ui/panes/root_pane.go +++ b/internal/ui/panes/root_pane.go @@ -1,6 +1,8 @@ package panes import ( + "sync" + "github.com/ja-he/dayplan/internal/input" "github.com/ja-he/dayplan/internal/ui" "github.com/ja-he/dayplan/internal/util" @@ -24,12 +26,17 @@ type RootPane struct { summary ui.Pane log ui.Pane - help ui.Pane - editor ui.Pane + help ui.Pane + + subpanesMtx sync.Mutex + subpanes []ui.Pane performanceMetricsOverlay ui.Pane inputProcessor input.ModalInputProcessor + + preDrawStackMtx sync.Mutex + preDrawStack []func() } // Dimensions gives the dimensions (x-axis offset, y-axis offset, width, @@ -63,11 +70,6 @@ func (p *RootPane) getCurrentlyActivePanesInOrder() (active []ui.Pane, inactive // TODO: this change breaks the cursor hiding, as that is done in the draw // call when !condition. it should be done differently anyways though, // imo. - if p.editor.IsVisible() { - active = append(active, p.editor) - } else { - inactive = append(inactive, p.editor) - } if p.log.IsVisible() { active = append(active, p.log) } else { @@ -78,6 +80,16 @@ func (p *RootPane) getCurrentlyActivePanesInOrder() (active []ui.Pane, inactive } else { inactive = append(inactive, p.summary) } + + for i := range p.subpanes { + if p.subpanes[i].IsVisible() { + active = append(active, p.subpanes[i]) + } else { + inactive = append(inactive, p.subpanes[i]) + } + } + + // TODO: help should probably be a subpane? for now, always on top. if p.help.IsVisible() { active = append(active, p.help) } else { @@ -91,15 +103,26 @@ func (p *RootPane) IsVisible() bool { return true } // Draw draws this pane. func (p *RootPane) Draw() { + p.preDrawStackMtx.Lock() + for _, f := range p.preDrawStack { + f() + } + p.preDrawStack = nil + p.preDrawStackMtx.Unlock() + + p.subpanesMtx.Lock() + defer p.subpanesMtx.Unlock() + p.renderer.Clear() - active, inactive := p.getCurrentlyActivePanesInOrder() + // FIXME: probably simplify this + active, _ := p.getCurrentlyActivePanesInOrder() for _, pane := range active { pane.Draw() } - for _, pane := range inactive { - pane.Undraw() - } + // for _, pane := range _ { + // pane.Undraw() + // } p.performanceMetricsOverlay.Draw() @@ -136,13 +159,26 @@ func (p *RootPane) CapturesInput() bool { // an action based on the input. // Defers to the panes' input processor or its focussed subpanes. func (p *RootPane) ProcessInput(key input.Key) bool { + if p.inputProcessor.CapturesInput() { + return p.inputProcessor.ProcessInput(key) + } else if p.focussedPane().CapturesInput() { + return p.focussedPane().ProcessInput(key) + } else { - return p.focussedPane().ProcessInput(key) || p.inputProcessor.ProcessInput(key) + + processAttemptResult := p.focussedPane().ProcessInput(key) + if processAttemptResult { + return true + } + + return p.inputProcessor.ProcessInput(key) + } + } func (p *RootPane) ViewUp() { @@ -196,18 +232,28 @@ func (p *RootPane) focussedPane() ui.Pane { switch { case p.help.IsVisible(): return p.help - case p.editor.IsVisible(): - return p.editor case p.summary.IsVisible(): return p.summary case p.log.IsVisible(): return p.log default: + for i := range p.subpanes { + if p.subpanes[i].IsVisible() { + return p.subpanes[i] + } + } return p.focussedViewPane } } func (p *RootPane) SetParent(ui.PaneQuerier) { panic("root set parent") } +// DeferPreDraw attaches a function to the pre-draw stack, which is executed +func (p *RootPane) DeferPreDraw(f func()) { + p.preDrawStackMtx.Lock() + p.preDrawStack = append(p.preDrawStack, f) + p.preDrawStackMtx.Unlock() +} + // ApplyModalOverlay applies an overlay to this processor. // It returns the processors index, by which in the future, all overlays down // to and including this overlay can be removed @@ -240,6 +286,25 @@ func (p *RootPane) GetHelp() input.Help { return result } +// PushSubpane allows adding a subpane over top of other subpanes. +func (p *RootPane) PushSubpane(pane ui.Pane) { + pane.SetParent(p) + p.subpanesMtx.Lock() + defer p.subpanesMtx.Unlock() + p.subpanes = append(p.subpanes, pane) +} + +// PopSubpane pops the topmost subpane +func (p *RootPane) PopSubpane() { + p.subpanesMtx.Lock() + defer p.subpanesMtx.Unlock() + if len(p.subpanes) == 0 { + return + } + p.DeferPreDraw(p.subpanes[len(p.subpanes)-1].Undraw) + p.subpanes = p.subpanes[:len(p.subpanes)-1] +} + // NewRootPane constructs and returns a new RootPane. func NewRootPane( renderer ui.RenderOrchestratorControl, @@ -250,7 +315,6 @@ func NewRootPane( summary ui.Pane, log ui.Pane, help ui.Pane, - editor ui.Pane, performanceMetricsOverlay ui.Pane, inputProcessor input.ModalInputProcessor, focussedPane ui.Pane, @@ -265,7 +329,6 @@ func NewRootPane( summary: summary, log: log, help: help, - editor: editor, performanceMetricsOverlay: performanceMetricsOverlay, inputProcessor: inputProcessor, focussedViewPane: focussedPane, @@ -276,7 +339,6 @@ func NewRootPane( summary.SetParent(rootPane) help.SetParent(rootPane) - editor.SetParent(rootPane) log.SetParent(rootPane) return rootPane diff --git a/internal/ui/panes/status_pane.go b/internal/ui/panes/status_pane.go index ef338591..4f1f2d83 100644 --- a/internal/ui/panes/status_pane.go +++ b/internal/ui/panes/status_pane.go @@ -1,7 +1,7 @@ package panes import ( - "github.com/ja-he/dayplan/internal/control" + "github.com/ja-he/dayplan/internal/control/edit" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/ui" @@ -11,7 +11,7 @@ import ( // StatusPane is a status bar that displays the current date, weekday, and - if // in a multi-day view - the progress through those days. type StatusPane struct { - Leaf + ui.LeafPane currentDate *model.Date @@ -20,50 +20,43 @@ type StatusPane struct { passedDaysInPeriod func() int firstDayXOffset func() int - eventEditMode func() control.EventEditMode -} - -// Dimensions gives the dimensions (x-axis offset, y-axis offset, width, -// height) for this pane. -// GetPositionInfo returns information on a requested position in this pane. -func (p *StatusPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + eventEditMode func() edit.EventEditMode } // Draw draws this pane. func (p *StatusPane) Draw() { - x, y, w, h := p.dimensions() + x, y, w, h := p.Dimensions() dateWidth := 10 // 2020-02-12 is 10 wide - bgStyle := p.stylesheet.Status + bgStyle := p.Stylesheet.Status bgStyleEmph := bgStyle.DefaultEmphasized() dateStyle := bgStyleEmph weekdayStyle := dateStyle.LightenedFG(60) // header background - p.renderer.DrawBox(0, y, p.firstDayXOffset()+p.totalDaysInPeriod()*p.dayWidth(), h, bgStyle) + p.Renderer.DrawBox(0, y, p.firstDayXOffset()+p.totalDaysInPeriod()*p.dayWidth(), h, bgStyle) // header bar (filled for days until current) - p.renderer.DrawBox(0, y, p.firstDayXOffset()+(p.passedDaysInPeriod())*p.dayWidth(), h, bgStyleEmph) + p.Renderer.DrawBox(0, y, p.firstDayXOffset()+(p.passedDaysInPeriod())*p.dayWidth(), h, bgStyleEmph) // date box background - p.renderer.DrawBox(0, y, dateWidth, h, bgStyleEmph) + p.Renderer.DrawBox(0, y, dateWidth, h, bgStyleEmph) // date string - p.renderer.DrawText(0, y, dateWidth, 1, dateStyle, p.currentDate.ToString()) + p.Renderer.DrawText(0, y, dateWidth, 1, dateStyle, p.currentDate.ToString()) // weekday string - p.renderer.DrawText(0, y+1, dateWidth, 1, weekdayStyle, util.TruncateAt(p.currentDate.ToWeekday().String(), dateWidth)) + p.Renderer.DrawText(0, y+1, dateWidth, 1, weekdayStyle, util.TruncateAt(p.currentDate.ToWeekday().String(), dateWidth)) // mode string modeStr := eventEditModeToString(p.eventEditMode()) - p.renderer.DrawText(x+w-len(modeStr)-2, y+h-1, len(modeStr), 1, bgStyleEmph.DarkenedBG(10).Italicized(), modeStr) + p.Renderer.DrawText(x+w-len(modeStr)-2, y+h-1, len(modeStr), 1, bgStyleEmph.DarkenedBG(10).Italicized(), modeStr) } -func eventEditModeToString(mode control.EventEditMode) string { +func eventEditModeToString(mode edit.EventEditMode) string { switch mode { - case control.EventEditModeNormal: + case edit.EventEditModeNormal: return "-- NORMAL --" - case control.EventEditModeMove: + case edit.EventEditModeMove: return "-- MOVE --" - case control.EventEditModeResize: + case edit.EventEditModeResize: return "-- RESIZE --" default: return "unknown" @@ -85,16 +78,16 @@ func NewStatusPane( totalDaysInPeriod func() int, passedDaysInPeriod func() int, firstDayXOffset func() int, - eventEditMode func() control.EventEditMode, + eventEditMode func() edit.EventEditMode, ) *StatusPane { return &StatusPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, currentDate: currentDate, dayWidth: dayWidth, diff --git a/internal/ui/panes/string_editor_ui_pane.go b/internal/ui/panes/string_editor_ui_pane.go new file mode 100644 index 00000000..7c53863f --- /dev/null +++ b/internal/ui/panes/string_editor_ui_pane.go @@ -0,0 +1,86 @@ +package panes + +import ( + "github.com/rs/zerolog/log" + + "github.com/ja-he/dayplan/internal/control/edit/views" + "github.com/ja-he/dayplan/internal/input" + "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" +) + +// StringEditorPane visualizes the editing of a string (as seen by a StringEditorView). +type StringEditorPane struct { + ui.LeafPane + + view views.StringEditorView + + cursorController ui.TextCursorController +} + +// Draw draws the editor popup. +func (p *StringEditorPane) Draw() { + if p.IsVisible() { + x, y, w, h := p.Dims() + + baseBGStyle := p.Stylesheet.Editor + if p.view.IsActive() { + baseBGStyle = baseBGStyle.DarkenedBG(10) + } + + p.Renderer.DrawBox(x, y, w, h, baseBGStyle) + p.Renderer.DrawText(x+1, y, 10, h, baseBGStyle.Italicized(), p.view.GetName()) + p.Renderer.DrawText(x+12, y, w-13, h, baseBGStyle.DarkenedBG(20), p.view.GetContent()) + + if p.view.IsActive() { + cursorX, cursorY := x+12+(p.view.GetCursorPos()), y + p.cursorController.ShowCursor(cursorX, cursorY) + log.Debug().Msgf("drawing cursor at %d, %d", cursorX, cursorY) + } else { + p.cursorController.HideCursor() + } + + // TODO(ja-he): wrap at word boundary; or something... + } +} + +// Undraw ensures that the cursor is hidden. +func (p *StringEditorPane) Undraw() { + p.cursorController.HideCursor() +} + +// GetPositionInfo returns information on a requested position in this pane (nil, for now). +func (p *StringEditorPane) GetPositionInfo(_, _ int) ui.PositionInfo { return nil } + +// ProcessInput attempts to process the provided input. +func (p *StringEditorPane) ProcessInput(k input.Key) bool { + if !p.view.IsActive() { + log.Warn().Msgf("string editor pane asked to process input despite view reporting not active; likely logic error") + } + return p.LeafPane.ProcessInput(k) +} + +// NewStringEditorPane creates a new StringEditorPane. +func NewStringEditorPane( + renderer ui.ConstrainedRenderer, + visible func() bool, + inputProcessor input.ModalInputProcessor, + view views.StringEditorView, + stylesheet styling.Stylesheet, + cursorController ui.TextCursorController, +) *StringEditorPane { + return &StringEditorPane{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ + ID: ui.GeneratePaneID(), + InputProcessor: inputProcessor, + Visible: visible, + }, + Renderer: renderer, + Dims: renderer.Dimensions, + Stylesheet: stylesheet, + }, + view: view, + cursorController: cursorController, + } +} diff --git a/internal/ui/panes/summary_pane.go b/internal/ui/panes/summary_pane.go index 3de8fcdd..5adde26d 100644 --- a/internal/ui/panes/summary_pane.go +++ b/internal/ui/panes/summary_pane.go @@ -14,7 +14,7 @@ import ( // It shows all events' times summed up (by Summarize, meaning without counting // any time multiple times) and visualizes the results in simple bars. type SummaryPane struct { - Leaf + ui.LeafPane titleString func() string days func() []*model.Day @@ -31,7 +31,7 @@ func (p *SummaryPane) EnsureHidden() {} // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *SummaryPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // Draw draws the time summary view over top of all previously drawn contents, @@ -41,10 +41,10 @@ func (p *SummaryPane) Draw() { if p.IsVisible() { x, y, w, h := p.Dimensions() - p.renderer.DrawBox(x, y, w, h, p.stylesheet.SummaryDefault) + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.SummaryDefault) title := p.titleString() - p.renderer.DrawBox(x, y, w, 1, p.stylesheet.SummaryTitleBox) - p.renderer.DrawText(x+(w/2-len(title)/2), y, len(title), 1, p.stylesheet.SummaryTitleBox, title) + p.Renderer.DrawBox(x, y, w, 1, p.Stylesheet.SummaryTitleBox) + p.Renderer.DrawText(x+(w/2-len(title)/2), y, len(title), 1, p.Stylesheet.SummaryTitleBox, title) summary := make(map[model.Category]int) @@ -77,15 +77,15 @@ func (p *SummaryPane) Draw() { duration := summary[category] style, err := p.categories.GetStyle(category) if err != nil { - style = p.stylesheet.CategoryFallback + style = p.Stylesheet.CategoryFallback } categoryStyling := style catLen := 20 durationLen := 20 barWidth := int(float64(duration) / float64(maxDuration) * float64(w-catLen-durationLen)) - p.renderer.DrawBox(x+catLen+durationLen, y+row, barWidth, 1, categoryStyling) - p.renderer.DrawText(x, y+row, catLen, 1, p.stylesheet.SummaryDefault, util.TruncateAt(category.Name, catLen)) - p.renderer.DrawText(x+catLen, y+row, durationLen, 1, categoryStyling, "("+util.DurationToString(duration)+")") + p.Renderer.DrawBox(x+catLen+durationLen, y+row, barWidth, 1, categoryStyling) + p.Renderer.DrawText(x, y+row, catLen, 1, p.Stylesheet.SummaryDefault, util.TruncateAt(category.Name, catLen)) + p.Renderer.DrawText(x+catLen, y+row, durationLen, 1, categoryStyling, "("+util.DurationToString(duration)+")") row++ } } @@ -108,15 +108,15 @@ func NewSummaryPane( inputProcessor input.ModalInputProcessor, ) *SummaryPane { return &SummaryPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), InputProcessor: inputProcessor, Visible: condition, }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, titleString: titleString, days: days, diff --git a/internal/ui/panes/timeline_pane.go b/internal/ui/panes/timeline_pane.go index e81202c2..f51aa216 100644 --- a/internal/ui/panes/timeline_pane.go +++ b/internal/ui/panes/timeline_pane.go @@ -2,6 +2,7 @@ package panes import ( "strings" + "time" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" @@ -13,20 +14,20 @@ import ( // and light on the timeline. If allowed to get a current time, it will // highlight the current time. type TimelinePane struct { - Leaf + ui.LeafPane suntimes func() *model.SunTimes currentTime func() *model.Timestamp - viewParams *ui.ViewParams + viewParams ui.TimespanViewParams } // Draw draws this pane. func (p *TimelinePane) Draw() { - x, y, w, h := p.dimensions() + x, y, w, h := p.Dims() - p.renderer.DrawBox(x, y, w, h, p.stylesheet.Normal) + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.Normal) suntimes := p.suntimes() currentTime := p.currentTime() @@ -36,7 +37,7 @@ func (p *TimelinePane) Draw() { timestampRPad := " " emptyTimestamp := strings.Repeat(" ", timestampLength) - if p.viewParams.NRowsPerHour == 0 { + if p.viewParams.HeightOfDuration(time.Hour) == 0 { panic("RES IS ZERO?!") } @@ -57,17 +58,17 @@ func (p *TimelinePane) Draw() { var styling styling.DrawStyling if suntimes != nil && (!(timestamp.IsAfter(suntimes.Rise)) || (timestamp.IsAfter(suntimes.Set))) { - styling = p.stylesheet.TimelineNight + styling = p.Stylesheet.TimelineNight } else { - styling = p.stylesheet.TimelineDay + styling = p.Stylesheet.TimelineDay } - p.renderer.DrawText(x, virtRow+y, w, 1, styling, timeText) + p.Renderer.DrawText(x, virtRow+y, w, 1, styling, timeText) } if currentTime != nil { timeText := timestampLPad + currentTime.ToString() + timestampRPad - p.renderer.DrawText(x, p.toY(*currentTime)+y, w, 1, p.stylesheet.TimelineNow, timeText) + p.Renderer.DrawText(x, p.toY(*currentTime)+y, w, 1, p.Stylesheet.TimelineNow, timeText) } } @@ -78,7 +79,7 @@ func (p *TimelinePane) GetPositionInfo(x, y int) ui.PositionInfo { // TODO: remove, this will be part of info returned to controller on query func (p *TimelinePane) timeAtY(y int) model.Timestamp { - minutes := y*(60/p.viewParams.NRowsPerHour) + p.viewParams.ScrollOffset*(60/p.viewParams.NRowsPerHour) + minutes := y*(60/int(p.viewParams.HeightOfDuration(time.Hour))) + p.viewParams.GetScrollOffset()*(60/int(p.viewParams.HeightOfDuration(time.Hour))) ts := model.Timestamp{Hour: minutes / 60, Minute: minutes % 60} @@ -86,7 +87,7 @@ func (p *TimelinePane) timeAtY(y int) model.Timestamp { } func (p *TimelinePane) toY(ts model.Timestamp) int { - return ((ts.Hour*p.viewParams.NRowsPerHour - p.viewParams.ScrollOffset) + (ts.Minute / (60 / p.viewParams.NRowsPerHour))) + return ((ts.Hour*int(p.viewParams.HeightOfDuration(time.Hour)) - p.viewParams.GetScrollOffset()) + (ts.Minute / (60 / int(p.viewParams.HeightOfDuration(time.Hour))))) } // NewTimelinePane constructs and returns a new TimelinePane. @@ -96,16 +97,16 @@ func NewTimelinePane( stylesheet styling.Stylesheet, suntimes func() *model.SunTimes, currentTime func() *model.Timestamp, - viewParams *ui.ViewParams, + viewParams ui.TimespanViewParams, ) *TimelinePane { return &TimelinePane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, suntimes: suntimes, currentTime: currentTime, diff --git a/internal/ui/panes/tools_pane.go b/internal/ui/panes/tools_pane.go index 07f0ff5b..f110988d 100644 --- a/internal/ui/panes/tools_pane.go +++ b/internal/ui/panes/tools_pane.go @@ -11,7 +11,7 @@ import ( // ToolsPane shows tools for editing. // Currently it only offers a selection of categories to select from. type ToolsPane struct { - Leaf + ui.LeafPane currentCategory *model.Category categories *styling.CategoryStyling @@ -25,25 +25,39 @@ type ToolsPane struct { // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *ToolsPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // Draw draws this pane. func (p *ToolsPane) Draw() { - x, y, w, h := p.dimensions() + if !p.IsVisible() { + return + } + + x, y, w, h := p.Dims() - style := p.stylesheet.Normal + style := p.Stylesheet.Normal if p.HasFocus() { - style = p.stylesheet.NormalEmphasized + style = p.Stylesheet.NormalEmphasized } - p.renderer.DrawBox(x, y, w, h, style) + p.Renderer.DrawBox(x, y, w, h, style) + + // title + func() { + style := p.Stylesheet.NormalEmphasized.DefaultEmphasized() + + p.Renderer.DrawBox(x, y, w, 1, style) - boxes := p.getCategoryBoxes(x, y, w, h) + titleText := "Tools" + p.Renderer.DrawText(x+(w/2)-(len(titleText)/2), y, len(titleText), 1, style.Bolded(), titleText) + }() + + boxes := p.getCategoryBoxes(x, y+1, w, h) for cat, box := range boxes { categoryStyle, err := p.categories.GetStyle(cat) var styling styling.DrawStyling if err != nil { - styling = p.stylesheet.CategoryFallback + styling = p.Stylesheet.CategoryFallback } else { styling = categoryStyle } @@ -55,14 +69,14 @@ func (p *ToolsPane) Draw() { styling = styling.Invert().Bolded() } - p.renderer.DrawBox(box.X, box.Y, box.W, box.H, styling) - p.renderer.DrawText(box.X+1, box.Y+textHeightOffset, textLen, 1, styling, util.TruncateAt(cat.Name, textLen)) + p.Renderer.DrawBox(box.X, box.Y, box.W, box.H, styling) + p.Renderer.DrawText(box.X+1, box.Y+textHeightOffset, textLen, 1, styling, util.TruncateAt(cat.Name, textLen)) } p.lastBoxesDrawn = boxes } func (p *ToolsPane) getCategoryBoxes(x, y, w, h int) map[model.Category]util.Rect { - i := 0 + i := y result := make(map[model.Category]util.Rect) @@ -94,26 +108,9 @@ func (p *ToolsPane) getCategoryForPos(x, y int) *model.Category { // GetPositionInfo returns information on a requested position in this pane. func (p *ToolsPane) GetPositionInfo(x, y int) ui.PositionInfo { - return ui.NewPositionInfo( - ui.ToolsPaneType, - nil, - nil, - &ToolsPanePositionInfo{category: p.getCategoryForPos(x, y)}, - nil, - nil, - ) -} - -// ToolsPanePositionInfo conveys information on a position in a tools pane, -// importantly the possible category displayed at that position. -type ToolsPanePositionInfo struct { - category *model.Category + return &ui.ToolsPanePositionInfo{Category: p.getCategoryForPos(x, y)} } -// Category gives the category at the position, or nil if none (e.g., because -// in padding space). -func (i *ToolsPanePositionInfo) Category() *model.Category { return i.category } - // NewToolsPane constructs and returns a new ToolsPane. func NewToolsPane( renderer ui.ConstrainedRenderer, @@ -125,16 +122,18 @@ func NewToolsPane( horizPadding int, vertPadding int, gap int, + visible func() bool, ) *ToolsPane { return &ToolsPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), InputProcessor: inputProcessor, + Visible: visible, }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, currentCategory: currentCategory, categories: categories, diff --git a/internal/ui/panes/weather_pane.go b/internal/ui/panes/weather_pane.go index eca3fcc5..879fac36 100644 --- a/internal/ui/panes/weather_pane.go +++ b/internal/ui/panes/weather_pane.go @@ -2,6 +2,7 @@ package panes import ( "fmt" + "time" "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" @@ -12,25 +13,25 @@ import ( // WeatherPane shows a timeline of hourly weather information blocks at a // timescale that can be in line with an similarly positioned TimelinePane. type WeatherPane struct { - Leaf + ui.LeafPane weather *weather.Handler currentDate *model.Date - viewParams *ui.ViewParams + viewParams ui.TimespanViewParams } // Dimensions gives the dimensions (x-axis offset, y-axis offset, width, // height) for this pane. // GetPositionInfo returns information on a requested position in this pane. func (p *WeatherPane) Dimensions() (x, y, w, h int) { - return p.dimensions() + return p.Dims() } // Draw draws this pane. func (p *WeatherPane) Draw() { x, y, w, h := p.Dimensions() - p.renderer.DrawBox(x, y, w, h, p.stylesheet.Normal) + p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.Normal) for timestamp := *model.NewTimestamp("00:00"); timestamp.Legal(); timestamp.Hour++ { row := p.toY(timestamp) @@ -45,28 +46,28 @@ func (p *WeatherPane) Draw() { weather, ok := p.weather.Data[index] if ok { - weatherStyling := p.stylesheet.WeatherNormal + weatherStyling := p.Stylesheet.WeatherNormal switch { case weather.PrecipitationProbability > .25: - weatherStyling = p.stylesheet.WeatherRainy + weatherStyling = p.Stylesheet.WeatherRainy case weather.Clouds < 25: - weatherStyling = p.stylesheet.WeatherSunny + weatherStyling = p.Stylesheet.WeatherSunny } - p.renderer.DrawBox(x, row, w, p.viewParams.NRowsPerHour, weatherStyling) + p.Renderer.DrawBox(x, row, w, int(p.viewParams.HeightOfDuration(time.Hour)), weatherStyling) - p.renderer.DrawText(x, row, w, 1, weatherStyling, weather.Info) - p.renderer.DrawText(x, row+1, w, 1, weatherStyling, fmt.Sprintf("%2.0f°C", weather.TempC)) - p.renderer.DrawText(x, row+2, w, 1, weatherStyling, fmt.Sprintf("%d%% clouds", weather.Clouds)) - p.renderer.DrawText(x, row+3, w, 1, weatherStyling, fmt.Sprintf("%d%% humidity", weather.Humidity)) - p.renderer.DrawText(x, row+4, w, 1, weatherStyling, fmt.Sprintf("%2.0f%% chance of rain", 100.0*weather.PrecipitationProbability)) + p.Renderer.DrawText(x, row, w, 1, weatherStyling, weather.Info) + p.Renderer.DrawText(x, row+1, w, 1, weatherStyling, fmt.Sprintf("%2.0f°C", weather.TempC)) + p.Renderer.DrawText(x, row+2, w, 1, weatherStyling, fmt.Sprintf("%d%% clouds", weather.Clouds)) + p.Renderer.DrawText(x, row+3, w, 1, weatherStyling, fmt.Sprintf("%d%% humidity", weather.Humidity)) + p.Renderer.DrawText(x, row+4, w, 1, weatherStyling, fmt.Sprintf("%2.0f%% chance of rain", 100.0*weather.PrecipitationProbability)) } } } // TODO: remove func (p *WeatherPane) toY(ts model.Timestamp) int { - return ((ts.Hour*p.viewParams.NRowsPerHour - p.viewParams.ScrollOffset) + (ts.Minute / (60 / p.viewParams.NRowsPerHour))) + return ((ts.Hour*int(p.viewParams.HeightOfDuration(time.Hour)) - p.viewParams.GetScrollOffset()) + (ts.Minute / (60 / int(p.viewParams.HeightOfDuration(time.Hour))))) } // GetPositionInfo returns information on a requested position in this pane. @@ -81,16 +82,16 @@ func NewWeatherPane( stylesheet styling.Stylesheet, currentDate *model.Date, weather *weather.Handler, - viewParams *ui.ViewParams, + viewParams ui.TimespanViewParams, ) *WeatherPane { return &WeatherPane{ - Leaf: Leaf{ - Base: Base{ + LeafPane: ui.LeafPane{ + BasePane: ui.BasePane{ ID: ui.GeneratePaneID(), }, - renderer: renderer, - dimensions: dimensions, - stylesheet: stylesheet, + Renderer: renderer, + Dims: dimensions, + Stylesheet: stylesheet, }, currentDate: currentDate, weather: weather, diff --git a/internal/ui/position_info.go b/internal/ui/position_info.go index 490a7685..9ead78de 100644 --- a/internal/ui/position_info.go +++ b/internal/ui/position_info.go @@ -8,113 +8,35 @@ import ( // // Retrievers should initially check for the type of pane they are receiving // information on and can then retreive the relevant additional information from -// the relevant `GetExtra...` function. -// Note that that information will likely be invalid, if it doesn't correspond -// to the pane type indicated. -type PositionInfo interface { - // TODO: rename PositionInformer (maybe?) +// whatever they got. +type PositionInfo interface{} - // The type of pane to which the information pertains. - PaneType() PaneType - - // Additional information on a position in a weather pane. - GetExtraWeatherInfo() WeatherPanePositionInfo - // Additional information on a position in a timeline pane. - GetExtraTimelineInfo() TimelinePanePositionInfo - // Additional information on a position in a events pane. - GetExtraEventsInfo() EventsPanePositionInfo - // Additional information on a position in a tools pane. - GetExtraToolsInfo() ToolsPanePositionInfo - // Additional information on a position in a status pane. - GetExtraStatusInfo() StatusPanePositionInfo - - // NOTE: additional functions to be expected, corresponding to pane types. -} +// NoPanePositionInfo is (no) information about no position. Comprende? +// (example: maybe pane that is none, has to return ~something~) +type NoPanePositionInfo struct{} // WeatherPanePositionInfo provides information on a position in a weather pane. -type WeatherPanePositionInfo interface{} +type WeatherPanePositionInfo struct{} // TimelinePanePositionInfo provides information on a position in a timeline // pane. -type TimelinePanePositionInfo interface{} +type TimelinePanePositionInfo struct{} -// ToolsPanePositionInfo provides information on a position in a tools pane. -type ToolsPanePositionInfo interface { - Category() *model.Category +// ToolsPanePositionInfo conveys information on a position in a tools pane, +// importantly the possible category displayed at that position. +type ToolsPanePositionInfo struct { + Category *model.Category } -// StatusPanePositionInfo provides information on a position in a status pane. -type StatusPanePositionInfo interface{} - -// EventsPanePositionInfo provides information on a position in a events pane. -type EventsPanePositionInfo interface { - Event() *model.Event - EventBoxPart() EventBoxPart - Time() model.Timestamp -} +// TasksPanePositionInfo provides information on a position in a tasks pane. +type TasksPanePositionInfo struct{} -// TUIPositionInfo provides information on a position in a TUI, implementing -// the PositionInfo interface. -type TUIPositionInfo struct { - paneType PaneType - weather WeatherPanePositionInfo - timeline TimelinePanePositionInfo - tools ToolsPanePositionInfo - status StatusPanePositionInfo - events EventsPanePositionInfo -} - -// GetExtraWeatherInfo provides additional information for a position in a -// weather pane. -func (t *TUIPositionInfo) GetExtraWeatherInfo() WeatherPanePositionInfo { - return nil -} - -// GetExtraTimelineInfo provides additional information for a position in a -// timeline pane. -func (t *TUIPositionInfo) GetExtraTimelineInfo() TimelinePanePositionInfo { - return nil -} - -// GetExtraToolsInfo provides additional information for a position in a tools -// pane. -func (t *TUIPositionInfo) GetExtraToolsInfo() ToolsPanePositionInfo { - return t.tools -} - -// GetExtraStatusInfo provides additional information for a position in a -// status pane. -func (t *TUIPositionInfo) GetExtraStatusInfo() StatusPanePositionInfo { - return nil -} - -// GetExtraEventsInfo provides additional information for a position in a -// events pane. -func (t *TUIPositionInfo) GetExtraEventsInfo() EventsPanePositionInfo { - return t.events -} - -// PaneType provides additional information for a position in a -func (t *TUIPositionInfo) PaneType() PaneType { - return t.paneType -} +// StatusPanePositionInfo provides information on a position in a status pane. +type StatusPanePositionInfo struct{} -// NewPositionInfo constructs and returns a PositionInfo from the given -// parameters. -func NewPositionInfo( - paneType PaneType, - weather WeatherPanePositionInfo, - timeline TimelinePanePositionInfo, - tools ToolsPanePositionInfo, - status StatusPanePositionInfo, - events EventsPanePositionInfo, -) PositionInfo { - return &TUIPositionInfo{ - paneType: paneType, - weather: weather, - timeline: timeline, - tools: tools, - status: status, - events: events, - } +// EventsPanePositionInfo provides information on a position in an EventsPane. +type EventsPanePositionInfo struct { + Event *model.Event + EventBoxPart EventBoxPart + Time model.Timestamp } diff --git a/internal/ui/single_day_view_params.go b/internal/ui/single_day_view_params.go new file mode 100644 index 00000000..3a96c678 --- /dev/null +++ b/internal/ui/single_day_view_params.go @@ -0,0 +1,84 @@ +package ui + +import ( + "fmt" + "time" + + "github.com/ja-he/dayplan/internal/model" + "github.com/rs/zerolog/log" +) + +// SingleDayViewParams represents the zoom and scroll of a timeline in the UI. +type SingleDayViewParams struct { + // NRowsPerHour is the number of rows in the UI that represent an hour in the + // timeline. + NRowsPerHour int + // ScrollOffset is the offset in rows by which the UI is scrolled. + // (An unscrolled UI would have 00:00 at the very top.) + ScrollOffset int +} + +// MinutesPerRow returns the number of minutes a single row represents. +func (p *SingleDayViewParams) DurationOfHeight(rows int) time.Duration { + return time.Duration(int64(60/float64(p.NRowsPerHour))) * time.Minute +} + +func (p *SingleDayViewParams) HeightOfDuration(dur time.Duration) float64 { + return float64(p.NRowsPerHour) * (float64(dur) / float64(time.Hour)) +} + +// TimeAtY is the time that corresponds to a given y-position. +func (p *SingleDayViewParams) TimeAtY(y int) model.Timestamp { + minutes := y*(60/p.NRowsPerHour) + p.ScrollOffset*(60/p.NRowsPerHour) + ts := model.Timestamp{Hour: minutes / 60, Minute: minutes % 60} + return ts +} + +// YForTime gives the y value the given timestamp would be at with the +// receiving ViewParams. +func (p *SingleDayViewParams) YForTime(time model.Timestamp) int { + return ((time.Hour*p.NRowsPerHour - p.ScrollOffset) + (time.Minute / (60 / p.NRowsPerHour))) +} + +func (p *SingleDayViewParams) GetScrollOffset() int { return p.ScrollOffset } +func (p *SingleDayViewParams) GetZoomPercentage() float64 { + switch p.NRowsPerHour { + case 6: + return 100 + case 3: + return 50 + case 12: + return 200 + default: + log.Fatal().Int("NRowsPerHour", p.NRowsPerHour).Msg("unexpected NRowsPerHour") + return 0 + } +} + +func (p *SingleDayViewParams) SetZoom(percentage float64) error { + switch percentage { + case 50: + p.NRowsPerHour = 3 + case 100: + p.NRowsPerHour = 6 + case 200: + p.NRowsPerHour = 12 + default: + return fmt.Errorf("invalid absolute zoom percentage %f for this view-param", percentage) + } + return nil +} +func (p *SingleDayViewParams) ChangeZoomBy(percentage float64) error { + switch { + case percentage == 50 && (p.NRowsPerHour == 12 || p.NRowsPerHour == 6): + p.NRowsPerHour /= 2 + return nil + case percentage == 200 && (p.NRowsPerHour == 6 || p.NRowsPerHour == 3): + p.NRowsPerHour *= 2 + return nil + case percentage == 100: + return nil + default: + return fmt.Errorf("invalid zoom change percentage %f for this view-param", percentage) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index aa7c34dd..de19d5ac 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -2,7 +2,6 @@ package ui import ( "github.com/ja-he/dayplan/internal/input" - "github.com/ja-he/dayplan/internal/model" "github.com/ja-he/dayplan/internal/styling" ) @@ -69,6 +68,8 @@ const ( EventsPaneType // ToolsPaneType represents a tools pane. ToolsPaneType + // TasksPaneType represents a tasks pane. + TasksPaneType // StatusPaneType represents a status pane (or status bar). StatusPaneType // EditorPaneType represents an editor (popup/floating) pane. @@ -171,9 +172,7 @@ func (p EventBoxPart) ToString() string { return "[unknown event box part]" } -// ConstrainedRenderer is a renderer that is assumed to be constrained to -// certain dimensions, i.E. it does not draw outside of them. -type ConstrainedRenderer interface { +type Renderer interface { // Draw a box of the indicated dimensions at the indicated location but // limited to the constraint (bounding box) of the renderer. // In the case that the box is not fully contained by the bounding box, @@ -188,6 +187,15 @@ type ConstrainedRenderer interface { DrawText(x, y, w, h int, style styling.DrawStyling, text string) } +// ConstrainedRenderer is a renderer that is assumed to be constrained to +// certain dimensions, i.E. it does not draw outside of them. +type ConstrainedRenderer interface { + Renderer + + // Dimensions returns the dimensions of the renderer. + Dimensions() (x, y, w, h int) +} + // RenderOrchestratorControl is the set of functions of a renderer (e.g., // tcell.Screen) that the root pane needs to use to have full control over a // render cycle. Other panes should not need this access to the renderer. @@ -196,40 +204,12 @@ type RenderOrchestratorControl interface { Show() } -// ViewParams represents the zoom and scroll of a timeline in the UI. -type ViewParams struct { - // NRowsPerHour is the number of rows in the UI that represent an hour in the - // timeline. - NRowsPerHour int - // ScrollOffset is the offset in rows by which the UI is scrolled. - // (An unscrolled UI would have 00:00 at the very top.) - ScrollOffset int -} - -// MinutesPerRow returns the number of minutes a single row represents. -func (p *ViewParams) MinutesPerRow() int { - return 60 / p.NRowsPerHour -} - // MouseCursorPos represents the position of a mouse cursor on the UI's // x-y-plane, which has its origin 0,0 in the top left. type MouseCursorPos struct { X, Y int } -// TimeAtY is the time that corresponds to a given y-position. -func (p *ViewParams) TimeAtY(y int) model.Timestamp { - minutes := y*(60/p.NRowsPerHour) + p.ScrollOffset*(60/p.NRowsPerHour) - ts := model.Timestamp{Hour: minutes / 60, Minute: minutes % 60} - return ts -} - -// YForTime gives the y value the given timestamp would be at with the -// receiving ViewParams. -func (p *ViewParams) YForTime(time model.Timestamp) int { - return ((time.Hour*p.NRowsPerHour - p.ScrollOffset) + (time.Minute / (60 / p.NRowsPerHour))) -} - // TextCursorController offers control of a text cursor, such as for a terminal. type TextCursorController interface { HideCursor() diff --git a/internal/ui/view_params.go b/internal/ui/view_params.go new file mode 100644 index 00000000..9448954b --- /dev/null +++ b/internal/ui/view_params.go @@ -0,0 +1,27 @@ +package ui + +import ( + "time" + + "github.com/ja-he/dayplan/internal/model" +) + +type ViewParams interface { + GetScrollOffset() int + GetZoomPercentage() float64 + + SetZoom(percentage float64) error + ChangeZoomBy(percentage float64) error +} + +type TimeViewParams interface { + ViewParams + DurationOfHeight(rows int) time.Duration + HeightOfDuration(time.Duration) float64 +} + +type TimespanViewParams interface { + TimeViewParams + TimeAtY(int) model.Timestamp + YForTime(model.Timestamp) int +}