diff --git a/internal/control/cli/controller.go b/internal/control/cli/controller.go index d0e11788..7c2ff5bc 100644 --- a/internal/control/cli/controller.go +++ b/internal/control/cli/controller.go @@ -105,14 +105,20 @@ func NewController( "": "move-cursor-rune-right", "0": "move-cursor-to-beginning", "$": "move-cursor-to-end", + "w": "move-cursor-to-next-word-beginning", + "b": "move-cursor-to-prev-word-beginning", + "e": "move-cursor-to-next-word-end", "": "quit", "D": "delete-to-end", "d$": "delete-to-end", + "d0": "backspace-to-beginning", "C": "delete-to-end-and-insert", "c$": "delete-to-end-and-insert", + "c0": "backspace-to-beginning-and-insert", "x": "delete-rune", "s": "delete-rune-and-insert", "i": "swap-mode-insert", + "a": "swap-mode-insert-append", }, Insert: map[input.Keyspec]input.Actionspec{ "": "move-cursor-rune-left", @@ -207,14 +213,7 @@ func NewController( screenWidth, screenHeight := screenSize() return 0, 0, screenWidth, screenHeight } - centeredFloat := func(floatWidth, floatHeight int) func() (x, y, w, h int) { - return func() (x, y, w, h int) { - screenWidth, screenHeight := screenSize() - return (screenWidth / 2) - (floatWidth / 2), (screenHeight / 2) - (floatHeight / 2), floatWidth, floatHeight - } - } helpDimensions := screenDimensions - editorDimensions := centeredFloat(editorWidth, editorHeight) tasksDimensions := func() (x, y, w, h int) { screenWidth, screenHeight := screenSize() return screenWidth - rightFlexWidth, 0, tasksWidth, screenHeight - statusHeight @@ -227,6 +226,12 @@ func NewController( screenWidth, screenHeight := screenSize() return 0, screenHeight - statusHeight, screenWidth, statusHeight } + editorDimensions := func() (x, y, w, h int) { + screenWidth, screenHeight := screenSize() + taskEditorBoxWidth := int(math.Min(float64(editorWidth), float64(screenWidth))) + taskEditorBoxHeight := int(math.Min(float64(editorHeight), float64(screenHeight))) + return (screenWidth / 2) - (taskEditorBoxWidth / 2), (screenHeight / 2) - (taskEditorBoxHeight / 2), taskEditorBoxWidth, taskEditorBoxHeight + } dayViewMainPaneDimensions := screenDimensions dayViewScrollablePaneDimensions := func() (x, y, w, h int) { parentX, parentY, parentW, parentH := dayViewMainPaneDimensions() @@ -460,7 +465,7 @@ func NewController( log.Warn().Msg("apparently, task editor was still active when a new one was activated, unexpected / error") } var err error - taskEditor, err := editors.ConstructEditor("root", task, nil, func() (bool, bool) { return true, true }) + taskEditor, err := editors.ConstructEditor("root", task, nil, nil) if err != nil { log.Error().Err(err).Interface("task", task).Msg("was not able to construct editor for task") return @@ -472,24 +477,21 @@ func NewController( controller.data.TaskEditor = nil 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 - }), + + taskEditorRenderer := ui.NewConstrainedRenderer(renderer, editorDimensions) + + taskEditorPane, err := panes.NewCompositeEditorPane( + taskEditorRenderer, + cursorWrangler, func() bool { return true }, inputConfig, stylesheet, - cursorWrangler, + controller.data.TaskEditor, ) if err != nil { - log.Error().Err(err).Msgf("could not construct task editor pane") - controller.data.TaskEditor = nil - return + log.Fatal().Err(err).Msg("could not construct task editor pane (this is likely a serious programming error / omission)") } - log.Info().Str("info", taskEditorPane.(*panes.CompositeEditorPane).GetDebugInfo()).Msg("here is the debug info for the task editor pane") + controller.rootPane.PushSubpane(taskEditorPane) taskEditorDone := make(chan struct{}) controller.data.TaskEditor.AddQuitCallback(func() { @@ -716,7 +718,6 @@ func NewController( } } var startMovePushing func() - var pushEditorAsRootSubpane func() // TODO: directly? eventsPaneDayInputExtension := map[input.Keyspec]action.Action{ "j": action.NewSimple(func() string { return "switch to next event" }, func() { @@ -741,10 +742,50 @@ func NewController( }), "": action.NewSimple(func() string { return "open the event editor" }, func() { event := controller.data.GetCurrentDay().Current - if event != nil { - controller.data.EventEditor.Activate(event) + if event == nil { + log.Warn().Msgf("ignoring event editing request since no current event selected") + return } - pushEditorAsRootSubpane() + + if controller.data.EventEditor != nil { + log.Warn().Msgf("was about to construct new event editor but still have old one") + return + } + newEventEditor, err := editors.ConstructEditor("event", event, nil, nil) + if err != nil { + log.Warn().Err(err).Msgf("unable to construct event editor") + return + } + var ok bool + controller.data.EventEditor, ok = newEventEditor.(*editors.Composite) + if !ok { + log.Error().Msgf("something went _really_ wrong and the editor constructed for the event is _not_ a composite editor but a %T", newEventEditor) + controller.data.EventEditor = nil + return + } + + eventEditorRenderer := ui.NewConstrainedRenderer(renderer, editorDimensions) + eventEditorPane, err := panes.NewCompositeEditorPane( + eventEditorRenderer, + cursorWrangler, + func() bool { return true }, + inputConfig, + stylesheet, + controller.data.EventEditor, + ) + if err != nil { + log.Fatal().Err(err).Msg("could not construct event editor pane (this is likely a serious programming error / omission)") + } + + controller.rootPane.PushSubpane(eventEditorPane) + eventEditorDone := make(chan struct{}) + controller.data.EventEditor.AddQuitCallback(func() { + close(eventEditorDone) // TODO: this can CERTAINLY happen twice; prevent + }) + go func() { + <-eventEditorDone + controller.controllerEvents <- ControllerEventEventEditorExit + }() }), "o": action.NewSimple(func() string { return "add event after selected" }, func() { current := controller.data.GetCurrentDay().Current @@ -1302,61 +1343,6 @@ func NewController( stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for summary pane") } - var editorStartInsertMode func() - var editorLeaveInsertMode func() - 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[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() }), - "a": action.NewSimple(func() string { return "enter insert mode (after character)" }, func() { - controller.data.EventEditor.MoveCursorRightA() - editorStartInsertMode() - }), - "A": action.NewSimple(func() string { return "enter insert mode (at end)" }, func() { - controller.data.EventEditor.MoveCursorPastEnd() - editorStartInsertMode() - }), - "0": action.NewSimple(func() string { return "move cursor to beginning" }, controller.data.EventEditor.MoveCursorToBeginning), - "$": action.NewSimple(func() string { return "move cursor to end" }, controller.data.EventEditor.MoveCursorToEnd), - "h": action.NewSimple(func() string { return "move cursor left" }, controller.data.EventEditor.MoveCursorLeft), - "l": action.NewSimple(func() string { return "move cursor right" }, controller.data.EventEditor.MoveCursorRight), - "w": action.NewSimple(func() string { return "move cursor to next word beginning" }, controller.data.EventEditor.MoveCursorNextWordBeginning), - "b": action.NewSimple(func() string { return "move cursor to previous word beginning" }, controller.data.EventEditor.MoveCursorPrevWordBeginning), - "e": action.NewSimple(func() string { return "move cursor to next word end" }, controller.data.EventEditor.MoveCursorNextWordEnd), - "x": action.NewSimple(func() string { return "delete character" }, controller.data.EventEditor.DeleteRune), - "C": action.NewSimple(func() string { return "delete to end" }, func() { - controller.data.EventEditor.DeleteToEnd() - editorStartInsertMode() - }), - "dd": action.NewSimple(func() string { return "clear text content" }, func() { controller.data.EventEditor.Clear() }), - "cc": action.NewSimple(func() string { return "clear text content, enter insert" }, func() { - controller.data.EventEditor.Clear() - editorStartInsertMode() - }), - }, - ) - if err != nil { - stderrLogger.Fatal().Err(err).Msg("failed to construct input tree for editor pane's normal mode") - } helpPaneInputTree, err := input.ConstructInputTree( map[input.Keyspec]action.Action{ "?": action.NewSimple(func() string { return "close help" }, func() { @@ -1374,26 +1360,6 @@ func NewController( func() bool { return controller.data.ShowHelp }, processors.NewModalInputProcessor(helpPaneInputTree), ) - editorPane := panes.NewEventEditorPane( - ui.NewConstrainedRenderer(renderer, editorDimensions), - cursorWrangler, - editorDimensions, - stylesheet, - func() bool { return controller.data.EventEditor.Active }, - func() string { return controller.data.EventEditor.TmpEventInfo.Name }, - controller.data.EventEditor.GetMode, - 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) - } - editorLeaveInsertMode = func() { - editorPane.PopModalOverlay() - controller.data.EventEditor.SetMode(input.TextEditModeNormal) - } rootPane := panes.NewRootPane( renderer, @@ -1489,7 +1455,6 @@ func NewController( helpPane.Content = rootPane.GetHelp() } - controller.data.EventEditor.SetMode(input.TextEditModeNormal) controller.data.EventEditMode = edit.EventEditModeNormal coordinatesProvided := (envData.Latitude != "" && envData.Longitude != "") @@ -1589,20 +1554,23 @@ func (t *Controller) ScrollBottom() { func (t *Controller) abortEdit() { t.data.MouseEditState = edit.MouseEditStateNone t.data.MouseEditedEvent = nil - t.data.EventEditor.Active = false + if t.data.EventEditor != nil { + t.data.EventEditor.Quit() + t.data.EventEditor = nil + } t.rootPane.PopSubpane() } func (t *Controller) endEdit() { t.data.MouseEditState = edit.MouseEditStateNone t.data.MouseEditedEvent = nil - if t.data.EventEditor.Active { - t.data.EventEditor.Active = false - tmp := t.data.EventEditor.TmpEventInfo - t.data.EventEditor.Original.Name = tmp.Name + if t.data.EventEditor != nil { + t.data.EventEditor.Write() + t.data.EventEditor.Quit() + t.data.EventEditor = nil } t.data.GetCurrentDay().UpdateEventOrder() - t.rootPane.PopSubpane() + t.rootPane.PopSubpane() // TODO: this will need to be re-done conceptually } func (t *Controller) startMouseMove(eventsInfo *ui.EventsPanePositionInfo) { @@ -1788,7 +1756,7 @@ func (t *Controller) handleMouseNoneEditEvent(e *tcell.EventMouse) { case ui.EventBoxInterior: t.startMouseMove(eventsInfo) case ui.EventBoxTopEdge: - t.data.EventEditor.Activate(eventsInfo.Event) + log.Info().Msgf("would construct editor here, once the programmer has figured out how to do so correctly") } case tcell.WheelUp: @@ -1876,6 +1844,7 @@ const ( ControllerEventExit ControllerEvent = iota ControllerEventRender ControllerEventTaskEditorExit + ControllerEventEventEditorExit ) // Empties all render events from the channel. @@ -1941,8 +1910,21 @@ func (t *Controller) Run() { go func() { t.controllerEvents <- ControllerEventRender }() } + case ControllerEventEventEditorExit: + if t.data.EventEditor == nil { + log.Warn().Msgf("got event editor exit event, but no event editor active; likely logic error") + } else { + t.data.EventEditor = nil + t.rootPane.PopSubpane() + log.Debug().Msgf("removed (presumed) event-editor subpane from root") + go func() { t.controllerEvents <- ControllerEventRender }() + } + case ControllerEventExit: return + + default: + log.Error().Interface("event", controllerEvent).Msgf("unhandled controller event") } } } diff --git a/internal/control/data.go b/internal/control/data.go index c615dc1a..3b480485 100644 --- a/internal/control/data.go +++ b/internal/control/data.go @@ -82,7 +82,7 @@ type ControlData struct { CurrentDate model.Date Weather weather.Handler - EventEditor editors.EventEditor + EventEditor *editors.Composite TaskEditor *editors.Composite ShowLog bool diff --git a/internal/control/edit/editor.go b/internal/control/edit/editor.go index 0dffaa04..e02bdbc5 100644 --- a/internal/control/edit/editor.go +++ b/internal/control/edit/editor.go @@ -2,20 +2,16 @@ // 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 { - IsActiveAndFocussed() (bool, bool) - GetName() string + // GetStatus informs on whether the editor is active and + // selected and focussed. + GetStatus() EditorStatus + + GetID() string GetType() string - GetSummary() SummaryEntry // Write the state of the editor. Write() @@ -25,18 +21,39 @@ type Editor interface { // AddQuitCallback adds a callback that is called when the editor is quit. AddQuitCallback(func()) - - // GetPane returns a pane that represents this editor. - GetPane( - renderer ui.ConstrainedRenderer, - visible func() bool, - inputConfig input.InputConfig, - stylesheet styling.Stylesheet, - cursorController ui.CursorLocationRequestHandler, - ) (ui.Pane, error) } -type SummaryEntry struct { - Representation any - Represents Editor -} +// EditorStatus informs on the status of an editor with respect to its +// selection. +type EditorStatus string + +const ( + // EditorInactive indicates that the editor is not active. + // + // In other words, the editor is not in the active chain. + EditorInactive EditorStatus = "inactive" + + // EditorSelected indicates that the editor is the one currently selected for + // editing within its parent, while its parent is focussed. + // + // In other words, the editor is not yet in the active chain but just beyond + // the end of it, and currently the "closest" to being added to the end of + // the chain, as only some sort of "confirm-selection" operation in the + // parent is now needed to focus this editor. + EditorSelected EditorStatus = "selected" + + // EditorFocussed indicates the editor is the editor that currently has focus + // and receives inputs. + // + // In other words, the editor is on the active chain and is the lowestmost on + // the chain, ie. the end of the chain. + EditorFocussed EditorStatus = "focussed" + + // EditorDescendantActive indicates that an descendent of the editor (a child, + // grandchild, ...) is active. + // + // In other words, the editor is in the active chain but is not the end of + // the chain, ie. there is at least one lower editor on the chain (a + // descendant). The editor may be the beginning of the active chain. + EditorDescendantActive EditorStatus = "descendant-active" +) diff --git a/internal/control/edit/editors/composite_editor.go b/internal/control/edit/editors/composite_editor.go index a9b6bf76..dc688d50 100644 --- a/internal/control/edit/editors/composite_editor.go +++ b/internal/control/edit/editors/composite_editor.go @@ -1,3 +1,4 @@ +// Package editors contains the editors for the different data types. package editors import ( @@ -11,30 +12,44 @@ import ( "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/model" - "github.com/ja-he/dayplan/internal/styling" - "github.com/ja-he/dayplan/internal/ui" - "github.com/ja-he/dayplan/internal/ui/panes" ) +// An EditorID is a unique identifier for an editor within a composite editor. +type EditorID = string + // Composite implements Editor type Composite struct { - fields []edit.Editor - activeFieldIndex int - inField bool + fields map[EditorID]edit.Editor + activeFieldID EditorID + fieldOrder []EditorID + inField bool - activeAndFocussedFunc func() (bool, bool) + parent *Composite - name string + id EditorID quitCallback func() } +func (e *Composite) getCurrentFieldIndex() int { + for i, id := range e.fieldOrder { + if id == e.activeFieldID { + return i + } + } + log.Warn().Msg("could not find a composite editor field index (will provide 0)") + return 0 +} + // SwitchToNextField switches to the next field (wrapping araound, if necessary) func (e *Composite) SwitchToNextField() { - nextIndex := (e.activeFieldIndex + 1) % len(e.fields) - log.Debug().Msgf("switching fields '%s' -> '%s'", e.fields[e.activeFieldIndex].GetName(), e.fields[nextIndex].GetName()) - // TODO: should _somehow_ signal deactivate to active field - e.activeFieldIndex = nextIndex + log.Trace().Interface("fieldOrder", e.fieldOrder).Msgf("switching to next field") + prevID := e.activeFieldID + indexOfCurrent := e.getCurrentFieldIndex() + nextIndex := (indexOfCurrent + 1) % len(e.fieldOrder) + nextID := e.fieldOrder[nextIndex] + log.Debug().Msgf("switching fields '%s' -> '%s'", e.fields[prevID].GetID(), e.fields[nextID].GetID()) + // TODO: should _somehow_ signal deactivate to active field (or perhaps not, not necessary in the current design imo) + e.activeFieldID = e.fieldOrder[nextIndex] } // GetType asserts that this is a composite editor. @@ -42,8 +57,11 @@ func (e *Composite) GetType() string { return "composite" } // 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) + prevID := e.activeFieldID + indexOfCurrent := e.getCurrentFieldIndex() + nextIndex := (indexOfCurrent - 1 + len(e.fieldOrder)) % len(e.fieldOrder) + log.Debug().Msgf("switching fields '%s' -> '%s'", e.fields[prevID].GetID(), e.fields[e.fieldOrder[nextIndex]].GetID()) + e.activeFieldID = e.fieldOrder[nextIndex] } // EnterField changes the editor to enter the currently selected field, e.g. @@ -56,7 +74,7 @@ func (e *Composite) EnterField() { } // ConstructEditor constructs a new editor... -func ConstructEditor[T any](name string, obj *T, extraSpec map[string]any, activeAndFocussedFunc func() (bool, bool)) (edit.Editor, error) { +func ConstructEditor(id string, obj any, extraSpec map[string]any, parentEditor *Composite) (edit.Editor, error) { structPtr := reflect.ValueOf(obj) if structPtr.Kind() != reflect.Ptr { @@ -71,11 +89,12 @@ func ConstructEditor[T any](name string, obj *T, extraSpec map[string]any, activ 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, - activeAndFocussedFunc: activeAndFocussedFunc, - name: name, + constructedCompositeEditor := &Composite{ + fields: make(map[EditorID]edit.Editor), + activeFieldID: "___unassigned", // NOTE: this must be done in the following + fieldOrder: nil, // NOTE: this must be done in the following + id: id, + parent: parentEditor, } // go through all tags @@ -88,8 +107,9 @@ func ConstructEditor[T any](name string, obj *T, extraSpec map[string]any, activ // build the edit spec editspec := dpedit{ - Name: parts[0], + ID: parts[0], } + log.Trace().Interface("dpedit", editspec).Msgf("have editspec") if len(parts) == 2 { switch parts[1] { case "ignore": @@ -103,76 +123,128 @@ func ConstructEditor[T any](name string, obj *T, extraSpec map[string]any, activ return nil, fmt.Errorf("field %d has too many (%d) parts in tag 'dpedit'", i, len(parts)) } - subeditorIndex := i - fieldActiveAndFocussed := func() (bool, bool) { - parentActive, parentFocussed := e.IsActiveAndFocussed() - selfActive := parentActive && parentFocussed && e.activeFieldIndex == subeditorIndex - return selfActive, selfActive && e.inField - } - // add the corresponding data to e (if not ignored) if !editspec.Ignore { + // set the active field to the first field + if constructedCompositeEditor.activeFieldID == "___unassigned" { + constructedCompositeEditor.activeFieldID = editspec.ID + } + constructedCompositeEditor.fieldOrder = append(constructedCompositeEditor.fieldOrder, editspec.ID) + 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, - ActiveAndFocussed: fieldActiveAndFocussed, + constructedCompositeEditor.fields[editspec.ID] = &StringEditor{ + ID: editspec.ID, + Content: f.String(), + CursorPos: 0, QuitCallback: func() { - if e.activeFieldIndex == subeditorIndex { - e.inField = false + if constructedCompositeEditor.activeFieldID == editspec.ID { + constructedCompositeEditor.inField = false } }, Mode: input.TextEditModeNormal, CommitFn: func(v string) { f.SetString(v) }, - }) + parent: constructedCompositeEditor, + } case reflect.Struct: if editspec.Ignore { - log.Debug().Msgf("ignoring struct '%s' tagged '%s' (ignore:%t)", field.Name, editspec.Name, editspec.Ignore) + log.Debug().Msgf("ignoring struct '%s' tagged '%s' (ignore:%t)", field.Name, editspec.ID, editspec.Ignore) } else { // construct the sub-editor for the struct f := structValue.Field(i) - typedSubfield, ok := f.Addr().Interface().(*model.Category) - if !ok { - return nil, fmt.Errorf("unable to cast field '%s' of type '%s' to model.Category", field.Name, field.Type.String()) + var fAsPtr any + if f.Kind() == reflect.Ptr { + fAsPtr = f.Interface() + } else { + fAsPtr = f.Addr().Interface() } - log.Debug().Msgf("constructing subeditor for field '%s' of type '%s'", field.Name, field.Type.String()) - sube, err := ConstructEditor(field.Name, typedSubfield, nil, fieldActiveAndFocussed) + log.Debug().Msgf("constructing subeditor for field '%s' (tagged '%s') of type '%s'", field.Name, editspec.ID, field.Type.String()) + sube, err := ConstructEditor(editspec.ID, fAsPtr, nil, constructedCompositeEditor) if err != nil { - return nil, fmt.Errorf("unable to construct subeditor for field '%s' of type '%s' (%s)", field.Name, field.Type.String(), err.Error()) + return nil, fmt.Errorf("unable to construct subeditor for field '%s' (tagged '%s') of type '%s' (%s)", field.Name, editspec.ID, field.Type.String(), err.Error()) } - sube.AddQuitCallback(func() { e.inField = false }) - log.Debug().Msgf("successfully constructed subeditor for field '%s' of type '%s'", field.Name, field.Type.String()) - e.fields = append(e.fields, sube) + sube.AddQuitCallback(func() { constructedCompositeEditor.inField = false }) + log.Debug().Msgf("successfully constructed subeditor for field '%s' (tagged '%s') of type '%s'", field.Name, editspec.ID, field.Type.String()) + constructedCompositeEditor.fields[editspec.ID] = sube } 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()) + log.Warn().Msgf("ignoring PTR '%s' tagged '%s' (ignore:%t) of type '%s'", field.Name, editspec.ID, 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 nil, fmt.Errorf("unable to edit non-ignored field '%s' (tagged '%s') of type '%s'", field.Name, editspec.ID, field.Type.Kind()) } } } } - log.Debug().Msgf("have (sub?)editor with %d fields", len(e.fields)) + if len(constructedCompositeEditor.fieldOrder) == 0 { + return nil, fmt.Errorf("could not find any fields to edit") + } + if constructedCompositeEditor.activeFieldID == "___unassigned" { + return nil, fmt.Errorf("could not find a field to set as active") + } + + log.Debug().Msgf("have (sub?)editor with %d fields", len(constructedCompositeEditor.fields)) + + return constructedCompositeEditor, nil +} - return e, nil +// GetStatus informs on whether the editor is active and focussed. +// +// "active" here means that the editor is in use, i.e. the user is currently +// editing within the editor. +// "focussed" means that the editor is the one currently receiving input, +// i.e. that it is the "lowestmost" active editor. +// +// E.g. when there is merely a single string editor, it must be active and +// focused. +// E.g. when there is a composite editor it must be active but the focus may +// lie with it or with a child editor. +func (e *Composite) GetStatus() edit.EditorStatus { + parentEditor := e.parent + // if there is no parent editor we are the root, ergo we can assume to have focus + if parentEditor == nil { + if e.inField { + return edit.EditorDescendantActive + } + return edit.EditorFocussed + } + parentStatus := parentEditor.GetStatus() + switch parentStatus { + case edit.EditorInactive, edit.EditorSelected: + return edit.EditorInactive + case edit.EditorDescendantActive, edit.EditorFocussed: + if parentEditor.activeFieldID == e.id { + if parentEditor.inField { + if e.inField { + return edit.EditorDescendantActive + } + return edit.EditorFocussed + } + return edit.EditorSelected + } + return edit.EditorInactive + default: + log.Error().Msgf("invalid edit state found (%s) likely logic error", parentStatus) + return edit.EditorInactive + } } type dpedit struct { - Name string + ID string Ignore bool Subedit bool } -// GetName returns the name of the editor. -func (e *Composite) GetName() string { return e.name } +// GetID returns the ID of the editor. +func (e *Composite) GetID() string { return e.id } + +// GetFieldOrder returns the order of the fields. +func (e *Composite) GetFieldOrder() []EditorID { return e.fieldOrder } // Write writes the content of the editor back to the underlying data structure // by calling the write functions of all subeditors. @@ -203,62 +275,12 @@ func (e *Composite) Quit() { if e.quitCallback != nil { e.quitCallback() } else { - log.Warn().Msgf("have no quit callback for editor '%s'", e.GetName()) - } -} - -// 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.CursorLocationRequestHandler, -) (ui.Pane, error) { - subpanes := []ui.Pane{} - - // TODO: this needs to compute an enriched version of the editor tree - editorSummary := e.GetSummary() - minX, minY, maxWidth, maxHeight := renderer.Dimensions() - uiBoxModel, err := translateToUIBoxModel(editorSummary, minX, minY, maxWidth, maxHeight) - if err != nil { - return nil, fmt.Errorf("error translating editor summary to UI box model (%s)", err.Error()) - } - log.Debug().Msgf("have UI box model: %s", uiBoxModel.String()) - - for _, child := range uiBoxModel.Children { - childX, childY, childW, childH := child.X, child.Y, child.W, child.H - subRenderer := ui.NewConstrainedRenderer(renderer, func() (int, int, int, int) { return childX, childY, childW, childH }) - subeditorPane, err := child.Represents.GetPane( - subRenderer, - visible, - inputConfig, - stylesheet, - cursorController, - ) - if err != nil { - return nil, fmt.Errorf("error constructing subpane of '%s' for subeditor '%s' (%s)", e.name, child.Represents.GetName(), err.Error()) - } - subpanes = append(subpanes, subeditorPane) - } - - inputProcessor, err := e.createInputProcessor(inputConfig) - if err != nil { - return nil, fmt.Errorf("could not construct input processor (%s)", err.Error()) + log.Warn().Msgf("have no quit callback for editor '%s'", e.GetID()) } - return panes.NewCompositeEditorPane( - renderer, - visible, - inputProcessor, - stylesheet, - subpanes, - func() int { return e.activeFieldIndex }, - func() bool { return e.inField }, - e, - ), nil } -func (e *Composite) createInputProcessor(cfg input.InputConfig) (input.ModalInputProcessor, error) { +// CreateInputProcessor creates an input processor for the editor. +func (e *Composite) CreateInputProcessor(cfg input.InputConfig) (input.ModalInputProcessor, error) { actionspecToFunc := map[input.Actionspec]func(){ "next-field": e.SwitchToNextField, "prev-field": e.SwitchToPrevField, @@ -282,68 +304,15 @@ func (e *Composite) createInputProcessor(cfg input.InputConfig) (input.ModalInpu return processors.NewModalInputProcessor(inputTree), nil } -func (e *Composite) IsActiveAndFocussed() (bool, bool) { return e.activeAndFocussedFunc() } - -func (e *Composite) GetSummary() edit.SummaryEntry { - - result := edit.SummaryEntry{ - Representation: []edit.SummaryEntry{}, - Represents: e, - } - for _, subeditor := range e.fields { - log.Debug().Msgf("constructing subpane of '%s' for subeditor '%s'", e.name, subeditor.GetName()) - result.Representation = append(result.Representation.([]edit.SummaryEntry), subeditor.GetSummary()) - } - - return result -} - -func translateToUIBoxModel(summary edit.SummaryEntry, minX, minY, maxWidth, maxHeight int) (ui.BoxRepresentation[edit.Editor], error) { - - switch repr := summary.Representation.(type) { - - // a slice indicates a composite - case []edit.SummaryEntry: - var children []ui.BoxRepresentation[edit.Editor] - computedHeight := 1 - rollingY := minY + 1 - for _, child := range repr { - childBoxRepresentation, err := translateToUIBoxModel(child, minX+1, rollingY, maxWidth-2, maxHeight-2) - if err != nil { - return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("error translating child '%s' (%s)", child.Represents.GetName(), err.Error()) - } - rollingY += childBoxRepresentation.H + 1 - children = append(children, childBoxRepresentation) - computedHeight += childBoxRepresentation.H + 1 - } - return ui.BoxRepresentation[edit.Editor]{ - X: minX, - Y: minY, - W: maxWidth, - H: computedHeight, - Represents: summary.Represents, - Children: children, - }, nil - - // a string indicates a leaf, i.e., a concrete editor rather than a composite - case string: - switch repr { - case "string": - return ui.BoxRepresentation[edit.Editor]{ - X: minX, - Y: minY, - W: maxWidth, - H: 1, - Represents: summary.Represents, - Children: nil, - }, nil - default: - return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("unknown editor identification value '%s'", repr) - } +// GetActiveFieldID returns the ID of the currently active field. +func (e *Composite) GetActiveFieldID() EditorID { return e.activeFieldID } - default: - return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("for editor '%s' have unknown type '%t'", summary.Represents.GetName(), summary.Representation) - - } +// IsInField informs on whether the editor is currently in a field. +func (e *Composite) IsInField() bool { return e.inField } +// GetFields returns the subeditors of this composite editor. +// +// TOOD: should this exist / be public (what is it good for)? +func (e *Composite) GetFields() map[EditorID]edit.Editor { + return e.fields } diff --git a/internal/control/edit/editors/event_editor.go b/internal/control/edit/editors/event_editor.go deleted file mode 100644 index 39d6d209..00000000 --- a/internal/control/edit/editors/event_editor.go +++ /dev/null @@ -1,169 +0,0 @@ -package editors - -import ( - "strconv" - - "github.com/ja-he/dayplan/internal/input" - "github.com/ja-he/dayplan/internal/model" -) - -type EventEditor struct { - Active bool - Original *model.Event - TmpEventInfo model.Event - CursorPos int - - Mode input.TextEditMode -} - -func (e *EventEditor) GetMode() input.TextEditMode { return e.Mode } -func (e *EventEditor) SetMode(m input.TextEditMode) { e.Mode = m } - -func (e *EventEditor) DeleteRune() { - tmpStr := []rune(e.TmpEventInfo.Name) - if e.CursorPos < len(tmpStr) { - preCursor := tmpStr[:e.CursorPos] - postCursor := tmpStr[e.CursorPos+1:] - - e.TmpEventInfo.Name = string(append(preCursor, postCursor...)) - } -} - -func (e *EventEditor) BackspaceRune() { - if e.CursorPos > 0 { - tmpStr := []rune(e.TmpEventInfo.Name) - preCursor := tmpStr[:e.CursorPos-1] - postCursor := tmpStr[e.CursorPos:] - - e.TmpEventInfo.Name = string(append(preCursor, postCursor...)) - e.CursorPos-- - } -} - -func (e *EventEditor) BackspaceToBeginning() { - nameAfterCursor := []rune(e.TmpEventInfo.Name)[e.CursorPos:] - e.TmpEventInfo.Name = string(nameAfterCursor) - e.CursorPos = 0 -} - -func (e *EventEditor) DeleteToEnd() { - nameBeforeCursor := []rune(e.TmpEventInfo.Name)[:e.CursorPos] - e.TmpEventInfo.Name = string(nameBeforeCursor) -} - -func (e *EventEditor) Clear() { - e.TmpEventInfo.Name = "" - e.CursorPos = 0 -} - -func (e *EventEditor) MoveCursorToBeginning() { - e.CursorPos = 0 -} - -func (e *EventEditor) MoveCursorToEnd() { - e.CursorPos = len([]rune(e.TmpEventInfo.Name)) - 1 -} - -func (e *EventEditor) MoveCursorPastEnd() { - e.CursorPos = len([]rune(e.TmpEventInfo.Name)) -} - -func (e *EventEditor) MoveCursorLeft() { - if e.CursorPos > 0 { - e.CursorPos-- - } -} - -func (e *EventEditor) MoveCursorRight() { - nameLen := len([]rune(e.TmpEventInfo.Name)) - if e.CursorPos+1 < nameLen { - e.CursorPos++ - } -} - -func (e *EventEditor) MoveCursorRightA() { - nameLen := len([]rune(e.TmpEventInfo.Name)) - if e.CursorPos < nameLen { - e.CursorPos++ - } -} - -func (e *EventEditor) MoveCursorNextWordBeginning() { - if len([]rune(e.TmpEventInfo.Name)) == 0 { - e.CursorPos = 0 - return - } - - nameAfterCursor := []rune(e.TmpEventInfo.Name)[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.TmpEventInfo.Name)) { - e.CursorPos = newCursorPos - } else { - e.MoveCursorToEnd() - } -} - -func (e *EventEditor) MoveCursorPrevWordBeginning() { - nameBeforeCursor := []rune(e.TmpEventInfo.Name)[: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 -} - -func (e *EventEditor) MoveCursorNextWordEnd() { - nameAfterCursor := []rune(e.TmpEventInfo.Name)[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.TmpEventInfo.Name)) { - e.CursorPos = newCursorPos - } else { - e.MoveCursorToEnd() - } -} - -func (e *EventEditor) AddRune(newRune rune) { - if strconv.IsPrint(newRune) { - tmpName := []rune(e.TmpEventInfo.Name) - cursorPos := e.CursorPos - if len(tmpName) == cursorPos { - tmpName = append(tmpName, newRune) - } else { - tmpName = append(tmpName[:cursorPos+1], tmpName[cursorPos:]...) - tmpName[cursorPos] = newRune - } - e.TmpEventInfo.Name = string(tmpName) - e.CursorPos++ - } -} - -func (e *EventEditor) Activate(event *model.Event) { - e.Active = true - e.TmpEventInfo = *event - e.Original = event - e.CursorPos = 0 -} diff --git a/internal/control/edit/editors/string_editor.go b/internal/control/edit/editors/string_editor.go index ec7e0ffb..cd79f408 100644 --- a/internal/control/edit/editors/string_editor.go +++ b/internal/control/edit/editors/string_editor.go @@ -8,9 +8,6 @@ import ( "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" "github.com/rs/zerolog/log" ) @@ -27,21 +24,22 @@ type StringEditorControl interface { MoveCursorPastEnd() MoveCursorLeft() MoveCursorRight() - MoveCursorRightA() - MoveCursorNextWordBeginning() - MoveCursorPrevWordBeginning() - MoveCursorNextWordEnd() + MoveCursorToNextWordBeginning() + MoveCursorToPrevWordBeginning() + MoveCursorToNextWordEnd() AddRune(newRune rune) } // StringEditor ... type StringEditor struct { - Name string + ID string - Content string - CursorPos int - Mode input.TextEditMode - ActiveAndFocussed func() (bool, bool) + Content string + CursorPos int + Mode input.TextEditMode + StatusFn func() edit.EditorStatus + + parent *Composite QuitCallback func() @@ -51,11 +49,32 @@ type StringEditor struct { // GetType asserts that this is a string editor. func (e *StringEditor) GetType() string { return "string" } -// IsActiveAndFocussed ... -func (e StringEditor) IsActiveAndFocussed() (bool, bool) { return e.ActiveAndFocussed() } +// GetStatus ... +func (e StringEditor) GetStatus() edit.EditorStatus { + if e.parent == nil { + return edit.EditorFocussed + } -// GetName returns the name of the editor. -func (e StringEditor) GetName() string { return e.Name } + parentEditorStatus := e.parent.GetStatus() + switch parentEditorStatus { + case edit.EditorInactive, edit.EditorSelected: + return edit.EditorInactive + case edit.EditorDescendantActive, edit.EditorFocussed: + if e.parent.activeFieldID == e.ID { + if e.parent.inField { + return edit.EditorFocussed + } + return edit.EditorSelected + } + return edit.EditorInactive + default: + log.Error().Msgf("invalid edit state found (%s) likely logic error", parentEditorStatus) + return edit.EditorInactive + } +} + +// GetID returns the ID of the editor. +func (e StringEditor) GetID() string { return e.ID } // GetContent returns the current (edited) contents. func (e StringEditor) GetContent() string { return e.Content } @@ -136,14 +155,15 @@ func (e *StringEditor) MoveCursorLeft() { // MoveCursorRight moves the cursor one rune to the right. func (e *StringEditor) MoveCursorRight() { nameLen := len([]rune(e.Content)) - if e.CursorPos+1 < nameLen { + allow := (e.Mode == input.TextEditModeInsert && e.CursorPos+1 <= nameLen) || (e.Mode == input.TextEditModeNormal && e.CursorPos+1 < nameLen) + if allow { e.CursorPos++ } } -// MoveCursorNextWordBeginning moves the cursor one rune to the right, or to +// MoveCursorToNextWordBeginning moves the cursor one rune to the right, or to // the end of the string if already at the end. -func (e *StringEditor) MoveCursorNextWordBeginning() { +func (e *StringEditor) MoveCursorToNextWordBeginning() { if len([]rune(e.Content)) == 0 { e.CursorPos = 0 return @@ -165,9 +185,9 @@ func (e *StringEditor) MoveCursorNextWordBeginning() { } } -// MoveCursorPrevWordBeginning moves the cursor one rune to the left, or to the +// MoveCursorToPrevWordBeginning moves the cursor one rune to the left, or to the // beginning of the string if already at the beginning. -func (e *StringEditor) MoveCursorPrevWordBeginning() { +func (e *StringEditor) MoveCursorToPrevWordBeginning() { nameBeforeCursor := []rune(e.Content)[:e.CursorPos] if len(nameBeforeCursor) == 0 { return @@ -182,8 +202,8 @@ func (e *StringEditor) MoveCursorPrevWordBeginning() { e.CursorPos = i } -// MoveCursorNextWordEnd moves the cursor to the end of the next word. -func (e *StringEditor) MoveCursorNextWordEnd() { +// MoveCursorToNextWordEnd moves the cursor to the end of the next word. +func (e *StringEditor) MoveCursorToNextWordEnd() { nameAfterCursor := []rune(e.Content)[e.CursorPos:] if len(nameAfterCursor) == 0 { return @@ -245,49 +265,32 @@ func (e *StringEditor) AddQuitCallback(f func()) { } } -// 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.CursorLocationRequestHandler, -) (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) { +// CreateInputProcessor creates an input processor for the editor. +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() }, + "move-cursor-rune-left": e.MoveCursorLeft, + "move-cursor-rune-right": e.MoveCursorRight, + "move-cursor-to-beginning": e.MoveCursorToBeginning, + "move-cursor-to-end": e.MoveCursorToEnd, + "move-cursor-to-next-word-beginning": e.MoveCursorToNextWordBeginning, + "move-cursor-to-prev-word-beginning": e.MoveCursorToPrevWordBeginning, + "move-cursor-to-next-word-end": e.MoveCursorToNextWordEnd, + "write": e.Write, + "quit": e.Quit, + "backspace": e.BackspaceRune, + "backspace-to-beginning": e.BackspaceToBeginning, + "backspace-to-beginning-and-insert": func() { e.BackspaceToBeginning(); enterInsertMode() }, + "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-insert-append": func() { enterInsertMode(); e.MoveCursorRight() }, + "swap-mode-normal": func() { exitInsertMode(); e.MoveCursorLeft() }, } normalModeMappings := map[input.Keyspec]action.Action{} @@ -325,10 +328,3 @@ func (e *StringEditor) createInputProcessor(cfg input.InputConfig) (input.ModalI return p, nil } - -func (e *StringEditor) GetSummary() edit.SummaryEntry { - return edit.SummaryEntry{ - Representation: "string", - Represents: e, - } -} diff --git a/internal/control/edit/views/composite_editor.go b/internal/control/edit/views/composite_editor.go deleted file mode 100644 index 91ed1359..00000000 --- a/internal/control/edit/views/composite_editor.go +++ /dev/null @@ -1,12 +0,0 @@ -package views - -// StringEditorView allows inspection of a string editor. -type CompositeEditorView interface { - - // IsActive signals whether THIS is active. (SHOULD BE MOVED TO A MORE GENERIC INTERFACE) - IsActiveAndFocussed() (bool, bool) - - GetName() string - - // TODO: more -} diff --git a/internal/control/edit/views/string_editor.go b/internal/control/edit/views/string_editor.go deleted file mode 100644 index d09de597..00000000 --- a/internal/control/edit/views/string_editor.go +++ /dev/null @@ -1,24 +0,0 @@ -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) - IsActiveAndFocussed() (bool, 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/model/event.go b/internal/model/event.go index 62a076af..2003e0bb 100644 --- a/internal/model/event.go +++ b/internal/model/event.go @@ -6,9 +6,10 @@ import ( ) type Event struct { - Start, End Timestamp - Name string - Cat Category + Name string `dpedit:"name"` + Cat Category `dpedit:"category"` + Start Timestamp `dpedit:",ignore"` + End Timestamp `dpedit:",ignore"` } func (e *Event) Duration() int { diff --git a/internal/ui/panes/composite_editor_ui_pane.go b/internal/ui/panes/composite_editor_ui_pane.go index 6c172953..c54f94f4 100644 --- a/internal/ui/panes/composite_editor_ui_pane.go +++ b/internal/ui/panes/composite_editor_ui_pane.go @@ -2,28 +2,27 @@ package panes import ( "fmt" - "math/rand" "github.com/rs/zerolog" "github.com/rs/zerolog/log" - "github.com/ja-he/dayplan/internal/control/edit/views" + "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/styling" "github.com/ja-he/dayplan/internal/ui" + "github.com/ja-he/dayplan/internal/util" ) // CompositeEditorPane visualizes a composite editor. type CompositeEditorPane struct { ui.LeafPane - getFocussedIndex func() int - isInField func() bool + getFocussedEditorID func() editors.EditorID + isInField func() bool - view views.CompositeEditorView - subpanes []ui.Pane - - bgoffs int + e *editors.Composite + subpanes map[editors.EditorID]ui.Pane log zerolog.Logger } @@ -34,24 +33,54 @@ func (p *CompositeEditorPane) Draw() { x, y, w, h := p.Dims() // draw background - style := p.Stylesheet.Editor.DarkenedBG(p.bgoffs) - active, focussed := p.view.IsActiveAndFocussed() - if active { - style = style.DarkenedBG(20) - } else if focussed { - style = style.DarkenedBG(40) - } + style := getAlteredStyleForEditorStatus(p.Stylesheet.Editor, p.e.GetStatus()) + p.Renderer.DrawBox(x, y, w, h, style) - p.Renderer.DrawText(x, y, w, 1, p.Stylesheet.Editor.DarkenedFG(20), p.view.GetName()) + p.Renderer.DrawText(x+1, y, w-2, 1, style, util.TruncateAt(p.e.GetID(), w-2)) + p.Renderer.DrawText(x, y, 1, 1, style.Bolded(), string(getRuneForEditorStatus(p.e.GetStatus()))) // draw all subpanes - for _, subpane := range p.subpanes { - subpane.Draw() + fieldOrderSlice := p.e.GetFieldOrder() + for i, id := range fieldOrderSlice { + subpane, ok := p.subpanes[id] + if !ok { + log.Warn().Msgf("comp: subpane '%s' (%d of %d) not found in subpanes (%v)", id, i, len(fieldOrderSlice), p.subpanes) + } else { + subpane.Draw() + } } } } +func getRuneForEditorStatus(status edit.EditorStatus) rune { + switch status { + case edit.EditorDescendantActive: + return '.' + case edit.EditorFocussed: + return '*' + case edit.EditorInactive: + return ' ' + case edit.EditorSelected: + return '>' + } + return '?' +} + +func getAlteredStyleForEditorStatus(baseStyle styling.DrawStyling, status edit.EditorStatus) styling.DrawStyling { + switch status { + case edit.EditorInactive: + return baseStyle.LightenedBG(10) + case edit.EditorSelected: + return baseStyle + case edit.EditorDescendantActive: + return baseStyle.DarkenedBG(10) + case edit.EditorFocussed: + return baseStyle.DarkenedBG(20).Bolded() + } + return baseStyle +} + // Undraw ensures that the cursor is hidden. func (p *CompositeEditorPane) Undraw() { for _, subpane := range p.subpanes { @@ -72,12 +101,13 @@ func (p *CompositeEditorPane) ProcessInput(key input.Key) bool { } 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)) + editorID := p.getFocussedEditorID() + focussedSubpane, ok := p.subpanes[editorID] + if !ok { + p.log.Error().Msgf("comp: somehow, have an invalid focussed pane '%s' not in (%v)", editorID, p.subpanes) return false } - processedBySubpane := p.subpanes[focussedIndex].ProcessInput(key) + processedBySubpane := focussedSubpane.ProcessInput(key) if processedBySubpane { return true } @@ -100,14 +130,46 @@ func (p *CompositeEditorPane) GetPositionInfo(_, _ int) ui.PositionInfo { return // NewCompositeEditorPane creates a new CompositeEditorPane. func NewCompositeEditorPane( renderer ui.ConstrainedRenderer, + cursorController ui.CursorLocationRequestHandler, visible func() bool, - inputProcessor input.ModalInputProcessor, + inputConfig input.InputConfig, stylesheet styling.Stylesheet, - subEditors []ui.Pane, - getFocussedIndex func() int, - isInField func() bool, - view views.CompositeEditorView, -) *CompositeEditorPane { + e *editors.Composite, +) (*CompositeEditorPane, error) { + + subpanes := map[editors.EditorID]ui.Pane{} + + minX, minY, maxWidth, maxHeight := renderer.Dimensions() + uiBoxModel, err := translateEditorsCompositeToTUI(e, minX, minY, maxWidth, maxHeight) + if err != nil { + return nil, fmt.Errorf("error translating editor summary to UI box model (%s)", err.Error()) + } + log.Debug().Msgf("have UI box model: %s", uiBoxModel.String()) + + for _, child := range uiBoxModel.Children { + childX, childY, childW, childH := child.X, child.Y, child.W, child.H + subRenderer := ui.NewConstrainedRenderer(renderer, func() (int, int, int, int) { return childX, childY, childW, childH }) + var subeditorPane ui.Pane + var err error + switch child := child.Represents.(type) { + case *editors.StringEditor: + subeditorPane, err = NewStringEditorPane(subRenderer, cursorController, visible, stylesheet, inputConfig, child) + case *editors.Composite: + subeditorPane, err = NewCompositeEditorPane(subRenderer, cursorController, visible, inputConfig, stylesheet, child) + default: + err = fmt.Errorf("unhandled subeditor type '%T' (forgot to handle case)", child) + } + if err != nil { + return nil, fmt.Errorf("error constructing subpane of '%s' for subeditor '%s' (%s)", e.GetID(), child.Represents.GetID(), err.Error()) + } + subpanes[child.Represents.GetID()] = subeditorPane + } + + inputProcessor, err := e.CreateInputProcessor(inputConfig) + if err != nil { + return nil, fmt.Errorf("could not construct input processor (%s)", err.Error()) + } + return &CompositeEditorPane{ LeafPane: ui.LeafPane{ BasePane: ui.BasePane{ @@ -119,13 +181,12 @@ func NewCompositeEditorPane( Dims: renderer.Dimensions, Stylesheet: stylesheet, }, - subpanes: subEditors, - getFocussedIndex: getFocussedIndex, - isInField: isInField, - log: log.With().Str("source", "composite-pane").Logger(), - bgoffs: 10 + rand.Intn(20), - view: view, - } + subpanes: subpanes, + getFocussedEditorID: e.GetActiveFieldID, + isInField: e.IsInField, + log: log.With().Str("source", "composite-pane").Logger(), + e: e, + }, nil } // GetHelp returns the input help map for this composite pane. @@ -138,7 +199,7 @@ func (p *CompositeEditorPane) GetHelp() input.Help { }() activeFieldHelp := func() input.Help { if p.isInField() { - return p.subpanes[p.getFocussedIndex()].GetHelp() + return p.subpanes[p.getFocussedEditorID()].GetHelp() } return input.Help{} }() @@ -152,20 +213,51 @@ func (p *CompositeEditorPane) GetHelp() input.Help { return result } -func (p *CompositeEditorPane) GetDebugInfo() string { - x, y, w, h := p.Dimensions() - info := fmt.Sprintf("[ +%d+%d:%dx%d ", x, y, w, h) - for _, subpane := range p.subpanes { - switch sp := subpane.(type) { - case *CompositeEditorPane: - info += sp.GetDebugInfo() - case *StringEditorPane: - x, y, w, h := sp.Dimensions() - info += fmt.Sprintf("( %d+%d:%dx%d )", x, y, w, h) - default: - info += fmt.Sprintf("", subpane) +func translateEditorsEditorToTUI(e edit.Editor, minX, minY, maxWidth, maxHeight int) (ui.BoxRepresentation[edit.Editor], error) { + + switch e := e.(type) { + + case *editors.Composite: + return translateEditorsCompositeToTUI(e, minX, minY, maxWidth, maxHeight) + + case *editors.StringEditor: + return ui.BoxRepresentation[edit.Editor]{ + X: minX, + Y: minY, + W: maxWidth, + H: 1, + Represents: e, + Children: nil, + }, nil + + default: + return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("unhandled editor type '%T' (forgot to handle case)", e) + + } + +} + +func translateEditorsCompositeToTUI(e *editors.Composite, minX, minY, maxWidth, maxHeight int) (ui.BoxRepresentation[edit.Editor], error) { + + var children []ui.BoxRepresentation[edit.Editor] + computedHeight := 1 + rollingY := minY + 1 + for _, child := range e.GetFields() { + childBoxRepresentation, err := translateEditorsEditorToTUI(child, minX+1, rollingY, maxWidth-2, maxHeight-2) + if err != nil { + return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("error translating child '%s' (%s)", child.GetID(), err.Error()) } + rollingY += childBoxRepresentation.H + 1 + children = append(children, childBoxRepresentation) + computedHeight += childBoxRepresentation.H + 1 } - info += "]" - return info + return ui.BoxRepresentation[edit.Editor]{ + X: minX, + Y: minY, + W: maxWidth, + H: computedHeight, + Represents: e, + Children: children, + }, nil + } diff --git a/internal/ui/panes/event_editor_pane.go b/internal/ui/panes/event_editor_pane.go deleted file mode 100644 index 2ea19ce4..00000000 --- a/internal/ui/panes/event_editor_pane.go +++ /dev/null @@ -1,96 +0,0 @@ -package panes - -import ( - "github.com/ja-he/dayplan/internal/input" - "github.com/ja-he/dayplan/internal/styling" - "github.com/ja-he/dayplan/internal/ui" -) - -// EventEditorPane visualizes the detailed editing of an event. -type EventEditorPane struct { - ui.LeafPane - - renderer ui.ConstrainedRenderer - cursorController ui.CursorLocationRequestHandler - dimensions func() (x, y, w, h int) - stylesheet styling.Stylesheet - - getMode func() input.TextEditMode - - name func() string - cursorPos func() int -} - -// Undraw ensures that the cursor is hidden. -func (p *EventEditorPane) Undraw() { - p.cursorController.Delete("event-editor-pane") -} - -// Dimensions gives the dimensions (x-axis offset, y-axis offset, width, -// height) for this pane. -func (p *EventEditorPane) Dimensions() (x, y, w, h int) { - return p.dimensions() -} - -// GetPositionInfo returns information on a requested position in this pane. -func (p *EventEditorPane) GetPositionInfo(x, y int) ui.PositionInfo { return nil } - -// Draw draws the editor popup. -func (p *EventEditorPane) Draw() { - if p.IsVisible() { - x, y, w, h := p.Dimensions() - - p.renderer.DrawBox(x, y, w, h, p.stylesheet.Editor) - p.renderer.DrawText(x+1, y+1, w-2, h-2, p.stylesheet.Editor, p.name()) - p.cursorController.Put(ui.CursorLocation{ - X: x + 1 + (p.cursorPos() % (w - 2)), - Y: y + 1 + (p.cursorPos() / (w - 2)), - }, "event-editor-pane") - // TODO(ja-he): wrap at word boundary - - mode := p.getMode() - var modeStr string - var style styling.DrawStyling - switch mode { - case input.TextEditModeNormal: - modeStr = "-- NORMAL --" - style = p.stylesheet.Editor.Italicized() - case input.TextEditModeInsert: - modeStr = "-- INSERT --" - style = p.stylesheet.Editor.DefaultEmphasized().Italicized().Bolded() - default: - panic("unknown text edit mode") - } - p.renderer.DrawText(x+4, y+h-2, len(modeStr), 1, style, modeStr) - } -} - -// NewEventEditorPane constructs and returns a new EventEditorPane. -func NewEventEditorPane( - renderer ui.ConstrainedRenderer, - cursorController ui.CursorLocationRequestHandler, - dimensions func() (x, y, w, h int), - stylesheet styling.Stylesheet, - condition func() bool, - name func() string, - getMode func() input.TextEditMode, - cursorPos func() int, - inputProcessor input.ModalInputProcessor, -) *EventEditorPane { - return &EventEditorPane{ - LeafPane: ui.LeafPane{ - BasePane: ui.BasePane{ - ID: ui.GeneratePaneID(), - InputProcessor: inputProcessor, - Visible: condition, - }, - }, - renderer: renderer, - cursorController: cursorController, - dimensions: dimensions, - stylesheet: stylesheet, - name: name, - getMode: getMode, - cursorPos: cursorPos, - } -} diff --git a/internal/ui/panes/string_editor_ui_pane.go b/internal/ui/panes/string_editor_ui_pane.go index 11eaa7c5..3ad870dc 100644 --- a/internal/ui/panes/string_editor_ui_pane.go +++ b/internal/ui/panes/string_editor_ui_pane.go @@ -1,10 +1,13 @@ package panes import ( + "fmt" + "github.com/google/uuid" "github.com/rs/zerolog/log" - "github.com/ja-he/dayplan/internal/control/edit/views" + "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/styling" "github.com/ja-he/dayplan/internal/ui" @@ -14,7 +17,7 @@ import ( type StringEditorPane struct { ui.LeafPane - view views.StringEditorView + e *editors.StringEditor cursorController ui.CursorLocationRequestHandler @@ -26,37 +29,36 @@ func (p *StringEditorPane) Draw() { if p.IsVisible() { x, y, w, h := p.Dims() - baseBGStyle := p.Stylesheet.Editor - active, focussed := p.view.IsActiveAndFocussed() - if active { - baseBGStyle = baseBGStyle.DarkenedBG(10) - } else if focussed { - baseBGStyle = baseBGStyle.DarkenedBG(20) - } + status := p.e.GetStatus() + baseStyle := getAlteredStyleForEditorStatus(p.Stylesheet.Editor, status) + boxStyle := baseStyle + fieldStyle := baseStyle + labelStyle := baseStyle nameWidth := 8 modeWidth := 5 padding := 1 - p.Renderer.DrawBox(x, y, w, h, baseBGStyle) - p.Renderer.DrawText(x+padding, y, nameWidth, h, baseBGStyle.Italicized(), p.view.GetName()) + p.Renderer.DrawBox(x, y, w, h, boxStyle) + p.Renderer.DrawText(x+padding, y, nameWidth, h, labelStyle, p.e.GetID()) + p.Renderer.DrawText(x, y, 1, 1, boxStyle.Bolded(), string(getRuneForEditorStatus(status))) - if focussed { - switch p.view.GetMode() { + if status == edit.EditorFocussed { + switch p.e.GetMode() { case input.TextEditModeInsert: - p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, baseBGStyle.DarkenedFG(30).Invert(), "(ins)") + p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, fieldStyle.DarkenedFG(30).Invert(), "(ins)") case input.TextEditModeNormal: - p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, baseBGStyle.DarkenedFG(30), "(nrm)") + p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, fieldStyle.DarkenedFG(30), "(nrm)") default: p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, p.Stylesheet.CategoryFallback, "( ? )") } } contentXOffset := padding + nameWidth + padding + modeWidth + padding - p.Renderer.DrawText(x+contentXOffset, y, w-contentXOffset+padding, h, baseBGStyle.DarkenedBG(20), p.view.GetContent()) + p.Renderer.DrawText(x+contentXOffset, y, w-contentXOffset+padding, h, fieldStyle, p.e.GetContent()) - if focussed { - cursorX, cursorY := x+contentXOffset+(p.view.GetCursorPos()), y + if status == edit.EditorFocussed { + cursorX, cursorY := x+contentXOffset+(p.e.GetCursorPos()), y p.cursorController.Put(ui.CursorLocation{X: cursorX, Y: cursorY}, p.idStr) } else { p.cursorController.Delete(p.idStr) @@ -76,8 +78,7 @@ func (p *StringEditorPane) GetPositionInfo(_, _ int) ui.PositionInfo { return ni // ProcessInput attempts to process the provided input. func (p *StringEditorPane) ProcessInput(k input.Key) bool { - active, _ := p.view.IsActiveAndFocussed() - if !active { + if p.e.GetStatus() == edit.EditorInactive { log.Warn().Msgf("string editor pane asked to process input despite view reporting not active; likely logic error") } return p.LeafPane.ProcessInput(k) @@ -86,12 +87,17 @@ func (p *StringEditorPane) ProcessInput(k input.Key) bool { // NewStringEditorPane creates a new StringEditorPane. func NewStringEditorPane( renderer ui.ConstrainedRenderer, + cursorController ui.CursorLocationRequestHandler, visible func() bool, - inputProcessor input.ModalInputProcessor, - view views.StringEditorView, stylesheet styling.Stylesheet, - cursorController ui.CursorLocationRequestHandler, -) *StringEditorPane { + inputConfig input.InputConfig, + e *editors.StringEditor, +) (*StringEditorPane, error) { + inputProcessor, err := e.CreateInputProcessor(inputConfig) + if err != nil { + return nil, fmt.Errorf("could not construct normal mode input tree (%s)", err.Error()) + } + return &StringEditorPane{ LeafPane: ui.LeafPane{ BasePane: ui.BasePane{ @@ -103,8 +109,8 @@ func NewStringEditorPane( Dims: renderer.Dimensions, Stylesheet: stylesheet, }, - view: view, + e: e, cursorController: cursorController, idStr: "string-editor-pane-" + uuid.Must(uuid.NewRandom()).String(), - } + }, nil }