diff --git a/go.mod b/go.mod index 7a25c8d7..335bd699 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( require ( github.com/gdamore/encoding v1.0.0 // indirect + github.com/google/uuid v1.5.0 // indirect github.com/mattn/go-colorable v0.1.12 // indirect github.com/mattn/go-isatty v0.0.14 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect diff --git a/go.sum b/go.sum index 7e754620..fa116af7 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo github.com/gdamore/tcell/v2 v2.7.0 h1:I5LiGTQuwrysAt1KS9wg1yFfOI3arI3ucFrxtd/xqaA= github.com/gdamore/tcell/v2 v2.7.0/go.mod h1:hl/KtAANGBecfIPxk+FzKvThTqI84oplgbPEmVX60b8= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/jessevdk/go-flags v1.5.0 h1:1jKYvbxEjfUl0fmqTCOfonvskHHXMjBySTLW4y9LFvc= github.com/jessevdk/go-flags v1.5.0/go.mod h1:Fw0T6WPc1dYxT4mKEZRfG5kJhaTDP9pj1c2EWnYs/m4= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= diff --git a/internal/control/cli/controller.go b/internal/control/cli/controller.go index fe47ef06..d0e11788 100644 --- a/internal/control/cli/controller.go +++ b/internal/control/cli/controller.go @@ -400,6 +400,8 @@ func NewController( func() edit.EventEditMode { return controller.data.EventEditMode }, ) + cursorWrangler := ui.NewCursorWrangler(renderer) + var currentTask *model.Task setCurrentTask := func(t *model.Task) { currentTask = t } backlogViewParams := ui.BacklogViewParams{ @@ -458,7 +460,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(task, nil) + taskEditor, err := editors.ConstructEditor("root", task, nil, func() (bool, bool) { return true, true }) if err != nil { log.Error().Err(err).Interface("task", task).Msg("was not able to construct editor for task") return @@ -480,13 +482,14 @@ func NewController( func() bool { return true }, inputConfig, stylesheet, - renderer, + cursorWrangler, ) if err != nil { log.Error().Err(err).Msgf("could not construct task editor pane") controller.data.TaskEditor = nil return } + 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() { @@ -1373,7 +1376,7 @@ func NewController( ) editorPane := panes.NewEventEditorPane( ui.NewConstrainedRenderer(renderer, editorDimensions), - renderer, + cursorWrangler, editorDimensions, stylesheet, func() bool { return controller.data.EventEditor.Active }, @@ -1394,6 +1397,7 @@ func NewController( rootPane := panes.NewRootPane( renderer, + cursorWrangler, screenDimensions, dayViewMainPane, diff --git a/internal/control/edit/editor.go b/internal/control/edit/editor.go index b6f5825d..0dffaa04 100644 --- a/internal/control/edit/editor.go +++ b/internal/control/edit/editor.go @@ -10,8 +10,13 @@ import ( // Editor is an interface for editing of objects (by the user). type Editor interface { + IsActiveAndFocussed() (bool, bool) + GetName() string + GetType() string + GetSummary() SummaryEntry + // Write the state of the editor. Write() @@ -21,15 +26,17 @@ type Editor interface { // 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, + cursorController ui.CursorLocationRequestHandler, ) (ui.Pane, error) } + +type SummaryEntry struct { + Representation any + Represents Editor +} diff --git a/internal/control/edit/editors/composite_editor.go b/internal/control/edit/editors/composite_editor.go index 9e51bab3..a9b6bf76 100644 --- a/internal/control/edit/editors/composite_editor.go +++ b/internal/control/edit/editors/composite_editor.go @@ -11,6 +11,7 @@ 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" @@ -22,16 +23,23 @@ type Composite struct { activeFieldIndex int inField bool + activeAndFocussedFunc func() (bool, bool) + name string quitCallback func() } // 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 = (e.activeFieldIndex + 1) % len(e.fields) + e.activeFieldIndex = nextIndex } +// GetType asserts that this is a composite editor. +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 @@ -48,7 +56,7 @@ func (e *Composite) EnterField() { } // ConstructEditor constructs a new editor... -func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, error) { +func ConstructEditor[T any](name string, obj *T, extraSpec map[string]any, activeAndFocussedFunc func() (bool, bool)) (edit.Editor, error) { structPtr := reflect.ValueOf(obj) if structPtr.Kind() != reflect.Ptr { @@ -64,9 +72,10 @@ func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, erro } e := &Composite{ - fields: nil, - activeFieldIndex: 0, - name: "root", + fields: nil, + activeFieldIndex: 0, + activeAndFocussedFunc: activeAndFocussedFunc, + name: name, } // go through all tags @@ -95,16 +104,22 @@ func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, erro } 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 { 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 }, + Name: editspec.Name, + Content: f.String(), + CursorPos: 0, + ActiveAndFocussed: fieldActiveAndFocussed, QuitCallback: func() { if e.activeFieldIndex == subeditorIndex { e.inField = false @@ -114,8 +129,26 @@ func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, erro 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()) + + if editspec.Ignore { + log.Debug().Msgf("ignoring struct '%s' tagged '%s' (ignore:%t)", field.Name, editspec.Name, 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()) + } + log.Debug().Msgf("constructing subeditor for field '%s' of type '%s'", field.Name, field.Type.String()) + sube, err := ConstructEditor(field.Name, typedSubfield, nil, fieldActiveAndFocussed) + 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()) + } + 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) + } + 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()) @@ -127,6 +160,8 @@ func ConstructEditor[T any](obj *T, extraSpec map[string]any) (edit.Editor, erro } + log.Debug().Msgf("have (sub?)editor with %d fields", len(e.fields)) + return e, nil } @@ -167,63 +202,46 @@ func (e *Composite) Quit() { } if e.quitCallback != nil { e.quitCallback() + } else { + log.Warn().Msgf("have no quit callback for editor '%s'", e.GetName()) } } -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, + cursorController ui.CursorLocationRequestHandler, ) (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 + // 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()) - 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 - }), + 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 for subeditor '%s' (%s)", subeditor.GetName(), err.Error()) + return nil, fmt.Errorf("error constructing subpane of '%s' for subeditor '%s' (%s)", e.name, child.Represents.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()) @@ -236,6 +254,7 @@ func (e *Composite) GetPane( subpanes, func() int { return e.activeFieldIndex }, func() bool { return e.inField }, + e, ), nil } @@ -262,3 +281,69 @@ 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) + } + + default: + return ui.BoxRepresentation[edit.Editor]{}, fmt.Errorf("for editor '%s' have unknown type '%t'", summary.Represents.GetName(), summary.Representation) + + } + +} diff --git a/internal/control/edit/editors/string_editor.go b/internal/control/edit/editors/string_editor.go index c51931b7..ec7e0ffb 100644 --- a/internal/control/edit/editors/string_editor.go +++ b/internal/control/edit/editors/string_editor.go @@ -5,6 +5,7 @@ import ( "strconv" "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" @@ -37,18 +38,21 @@ type StringEditorControl interface { type StringEditor struct { Name string - Content string - CursorPos int - Mode input.TextEditMode - Active func() bool + Content string + CursorPos int + Mode input.TextEditMode + ActiveAndFocussed func() (bool, bool) QuitCallback func() CommitFn func(string) } -// IsActive ... -func (e StringEditor) IsActive() bool { return e.Active() } +// 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() } // GetName returns the name of the editor. func (e StringEditor) GetName() string { return e.Name } @@ -247,7 +251,7 @@ func (e *StringEditor) GetPane( visible func() bool, inputConfig input.InputConfig, stylesheet styling.Stylesheet, - cursorController ui.TextCursorController, + cursorController ui.CursorLocationRequestHandler, ) (ui.Pane, error) { inputProcessor, err := e.createInputProcessor(inputConfig) if err != nil { @@ -321,3 +325,10 @@ 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 new file mode 100644 index 00000000..91ed1359 --- /dev/null +++ b/internal/control/edit/views/composite_editor.go @@ -0,0 +1,12 @@ +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 index 7b4613b7..d09de597 100644 --- a/internal/control/edit/views/string_editor.go +++ b/internal/control/edit/views/string_editor.go @@ -6,7 +6,7 @@ import "github.com/ja-he/dayplan/internal/input" type StringEditorView interface { // IsActive signals whether THIS is active. (SHOULD BE MOVED TO A MORE GENERIC INTERFACE) - IsActive() bool + IsActiveAndFocussed() (bool, bool) // GetMode returns the current mode of the editor. GetMode() input.TextEditMode diff --git a/internal/model/category.go b/internal/model/category.go index e5a31e5c..f321414b 100644 --- a/internal/model/category.go +++ b/internal/model/category.go @@ -1,10 +1,10 @@ package model type Category struct { - Name string - Priority int - Goal Goal - Deprecated bool + Name string `dpedit:"name"` + Priority int `dpeditr:"priority"` + Goal Goal `dpedit:",ignore"` + Deprecated bool `dpedit:",ignore"` } type ByName []Category diff --git a/internal/tui/screen_handler.go b/internal/tui/screen_handler.go index fb3e71aa..5c0284dc 100644 --- a/internal/tui/screen_handler.go +++ b/internal/tui/screen_handler.go @@ -5,6 +5,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/ja-he/dayplan/internal/styling" + "github.com/ja-he/dayplan/internal/ui" ) // ScreenHandler allows rendering to a terminal (via tcell.Screen). @@ -66,8 +67,8 @@ func (s *ScreenHandler) Dimensions() (x, y, w, h int) { } // ShowCursor sets the position of the text cursor. -func (s *ScreenHandler) ShowCursor(x, y int) { - s.screen.ShowCursor(x, y) +func (s *ScreenHandler) ShowCursor(loc ui.CursorLocation) { + s.screen.ShowCursor(loc.X, loc.Y) } // HideCursor hides the text cursor. diff --git a/internal/ui/cursor_wrangler.go b/internal/ui/cursor_wrangler.go new file mode 100644 index 00000000..ef8ea77c --- /dev/null +++ b/internal/ui/cursor_wrangler.go @@ -0,0 +1,91 @@ +package ui + +import ( + "sync" + + "github.com/rs/zerolog/log" +) + +// CursorLocationRequestHandler is an interface for a type that can handle +// requests to place a (text/terminal) cursor on the screen. +type CursorLocationRequestHandler interface { + Put(l CursorLocation, requesterID string) + Delete(requesterID string) +} + +// CursorWrangler handles requests to place a (text/terminal) cursor on the +// screen. +type CursorWrangler struct { + mtx sync.RWMutex + + cc TextCursorController + + desiredLocation *CursorLocation + mostRecentRequester *string + + enactedLocation *CursorLocation +} + +// NewCursorWrangler creates a new CursorWrangler. +func NewCursorWrangler(controller TextCursorController) *CursorWrangler { + return &CursorWrangler{ + cc: controller, + desiredLocation: nil, + mostRecentRequester: nil, + } +} + +// Put places the cursor at the given location. +func (w *CursorWrangler) Put(l CursorLocation, requesterID string) { + w.mtx.Lock() + defer w.mtx.Unlock() + + if w.desiredLocation != nil && *w.mostRecentRequester != requesterID { + log.Warn().Msgf("being asked to put cursor (at %s) while it is already placed by '%s' (at %s); will be overwritten", l.String(), *w.mostRecentRequester, w.desiredLocation.String()) + } + + log.Trace().Msgf("updating cursor location to %s (from %s)", l.String(), requesterID) + w.desiredLocation = &l + w.mostRecentRequester = &requesterID +} + +// Delete removes the cursor. +func (w *CursorWrangler) Delete(requesterID string) { + w.mtx.Lock() + defer w.mtx.Unlock() + + if w.mostRecentRequester == nil { + log.Debug().Msgf("ignoring '%s's request to delete cursor, as there is no active request for a cursor (i.e., the requester's intention is already met, another requester had already superseded their request)", requesterID) + return + } + + if *w.mostRecentRequester != requesterID { + log.Debug().Msgf("ignoring '%s's request to delete cursor, as current requestor is %s", requesterID, *w.mostRecentRequester) + return + } + + log.Trace().Msgf("deleting cursor (was at %s from %s)", w.desiredLocation.String(), requesterID) + w.desiredLocation = nil + w.mostRecentRequester = nil +} + +// Enact enacts the current cursor location request via the underlying +// cursor controller. +func (w *CursorWrangler) Enact() { + w.mtx.Lock() + defer w.mtx.Unlock() + + if w.desiredLocation != nil { + if w.enactedLocation == nil || (*w.enactedLocation != *w.desiredLocation) { + log.Trace().Msgf("showing cursor at %s", w.desiredLocation.String()) + w.cc.ShowCursor(*w.desiredLocation) + w.enactedLocation = w.desiredLocation + } + } else { + if w.enactedLocation != nil { + log.Trace().Msgf("hiding cursor") + w.cc.HideCursor() + w.enactedLocation = nil + } + } +} diff --git a/internal/ui/panes/composite_editor_ui_pane.go b/internal/ui/panes/composite_editor_ui_pane.go index fa065b19..6c172953 100644 --- a/internal/ui/panes/composite_editor_ui_pane.go +++ b/internal/ui/panes/composite_editor_ui_pane.go @@ -1,9 +1,13 @@ 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/input" "github.com/ja-he/dayplan/internal/styling" "github.com/ja-he/dayplan/internal/ui" @@ -16,8 +20,11 @@ type CompositeEditorPane struct { getFocussedIndex func() int isInField func() bool + view views.CompositeEditorView subpanes []ui.Pane + bgoffs int + log zerolog.Logger } @@ -27,7 +34,15 @@ func (p *CompositeEditorPane) Draw() { x, y, w, h := p.Dims() // draw background - p.Renderer.DrawBox(x, y, w, h, p.Stylesheet.Editor) + 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) + } + p.Renderer.DrawBox(x, y, w, h, style) + p.Renderer.DrawText(x, y, w, 1, p.Stylesheet.Editor.DarkenedFG(20), p.view.GetName()) // draw all subpanes for _, subpane := range p.subpanes { @@ -91,6 +106,7 @@ func NewCompositeEditorPane( subEditors []ui.Pane, getFocussedIndex func() int, isInField func() bool, + view views.CompositeEditorView, ) *CompositeEditorPane { return &CompositeEditorPane{ LeafPane: ui.LeafPane{ @@ -107,6 +123,8 @@ func NewCompositeEditorPane( getFocussedIndex: getFocussedIndex, isInField: isInField, log: log.With().Str("source", "composite-pane").Logger(), + bgoffs: 10 + rand.Intn(20), + view: view, } } @@ -133,3 +151,21 @@ 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) + } + } + info += "]" + return info +} diff --git a/internal/ui/panes/event_editor_pane.go b/internal/ui/panes/event_editor_pane.go index 50704a38..2ea19ce4 100644 --- a/internal/ui/panes/event_editor_pane.go +++ b/internal/ui/panes/event_editor_pane.go @@ -11,7 +11,7 @@ type EventEditorPane struct { ui.LeafPane renderer ui.ConstrainedRenderer - cursorController ui.TextCursorController + cursorController ui.CursorLocationRequestHandler dimensions func() (x, y, w, h int) stylesheet styling.Stylesheet @@ -23,7 +23,7 @@ type EventEditorPane struct { // Undraw ensures that the cursor is hidden. func (p *EventEditorPane) Undraw() { - p.cursorController.HideCursor() + p.cursorController.Delete("event-editor-pane") } // Dimensions gives the dimensions (x-axis offset, y-axis offset, width, @@ -42,7 +42,10 @@ func (p *EventEditorPane) Draw() { 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.ShowCursor(x+1+(p.cursorPos()%(w-2)), y+1+(p.cursorPos()/(w-2))) + 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() @@ -65,7 +68,7 @@ func (p *EventEditorPane) Draw() { // NewEventEditorPane constructs and returns a new EventEditorPane. func NewEventEditorPane( renderer ui.ConstrainedRenderer, - cursorController ui.TextCursorController, + cursorController ui.CursorLocationRequestHandler, dimensions func() (x, y, w, h int), stylesheet styling.Stylesheet, condition func() bool, diff --git a/internal/ui/panes/root_pane.go b/internal/ui/panes/root_pane.go index 84eaa33c..bc6b9200 100644 --- a/internal/ui/panes/root_pane.go +++ b/internal/ui/panes/root_pane.go @@ -13,7 +13,8 @@ import ( type RootPane struct { ID ui.PaneID - renderer ui.RenderOrchestratorControl + renderer ui.RenderOrchestratorControl + cursorWrangler *ui.CursorWrangler dimensions func() (x, y, w, h int) @@ -103,6 +104,7 @@ func (p *RootPane) IsVisible() bool { return true } // Draw draws this pane. func (p *RootPane) Draw() { + p.preDrawStackMtx.Lock() for _, f := range p.preDrawStack { f() @@ -126,6 +128,10 @@ func (p *RootPane) Draw() { p.performanceMetricsOverlay.Draw() + // After all drawing draw or hide the cursor, depending on what is requested + // during the draw of subpanes. + p.cursorWrangler.Enact() + p.renderer.Show() } @@ -308,6 +314,7 @@ func (p *RootPane) PopSubpane() { // NewRootPane constructs and returns a new RootPane. func NewRootPane( renderer ui.RenderOrchestratorControl, + cursorWrangler *ui.CursorWrangler, dimensions func() (x, y, w, h int), dayViewMainPane *Composite, weekViewMainPane *Composite, @@ -322,6 +329,7 @@ func NewRootPane( rootPane := &RootPane{ ID: ui.GeneratePaneID(), renderer: renderer, + cursorWrangler: cursorWrangler, dimensions: dimensions, dayViewMainPane: dayViewMainPane, weekViewMainPane: weekViewMainPane, diff --git a/internal/ui/panes/string_editor_ui_pane.go b/internal/ui/panes/string_editor_ui_pane.go index 43d7816d..11eaa7c5 100644 --- a/internal/ui/panes/string_editor_ui_pane.go +++ b/internal/ui/panes/string_editor_ui_pane.go @@ -1,6 +1,7 @@ package panes import ( + "github.com/google/uuid" "github.com/rs/zerolog/log" "github.com/ja-he/dayplan/internal/control/edit/views" @@ -15,7 +16,9 @@ type StringEditorPane struct { view views.StringEditorView - cursorController ui.TextCursorController + cursorController ui.CursorLocationRequestHandler + + idStr string } // Draw draws the editor popup. @@ -24,8 +27,11 @@ func (p *StringEditorPane) Draw() { x, y, w, h := p.Dims() baseBGStyle := p.Stylesheet.Editor - if p.view.IsActive() { + active, focussed := p.view.IsActiveAndFocussed() + if active { baseBGStyle = baseBGStyle.DarkenedBG(10) + } else if focussed { + baseBGStyle = baseBGStyle.DarkenedBG(20) } nameWidth := 8 @@ -35,7 +41,7 @@ func (p *StringEditorPane) Draw() { p.Renderer.DrawBox(x, y, w, h, baseBGStyle) p.Renderer.DrawText(x+padding, y, nameWidth, h, baseBGStyle.Italicized(), p.view.GetName()) - if p.view.IsActive() { + if focussed { switch p.view.GetMode() { case input.TextEditModeInsert: p.Renderer.DrawText(x+padding+nameWidth+padding, y, modeWidth, h, baseBGStyle.DarkenedFG(30).Invert(), "(ins)") @@ -49,12 +55,11 @@ func (p *StringEditorPane) Draw() { contentXOffset := padding + nameWidth + padding + modeWidth + padding p.Renderer.DrawText(x+contentXOffset, y, w-contentXOffset+padding, h, baseBGStyle.DarkenedBG(20), p.view.GetContent()) - if p.view.IsActive() { + if focussed { cursorX, cursorY := x+contentXOffset+(p.view.GetCursorPos()), y - p.cursorController.ShowCursor(cursorX, cursorY) - log.Debug().Msgf("drawing cursor at %d, %d", cursorX, cursorY) + p.cursorController.Put(ui.CursorLocation{X: cursorX, Y: cursorY}, p.idStr) } else { - p.cursorController.HideCursor() + p.cursorController.Delete(p.idStr) } // TODO(ja-he): wrap at word boundary; or something... @@ -63,7 +68,7 @@ func (p *StringEditorPane) Draw() { // Undraw ensures that the cursor is hidden. func (p *StringEditorPane) Undraw() { - p.cursorController.HideCursor() + p.cursorController.Delete(p.idStr) } // GetPositionInfo returns information on a requested position in this pane (nil, for now). @@ -71,7 +76,8 @@ func (p *StringEditorPane) GetPositionInfo(_, _ int) ui.PositionInfo { return ni // ProcessInput attempts to process the provided input. func (p *StringEditorPane) ProcessInput(k input.Key) bool { - if !p.view.IsActive() { + active, _ := p.view.IsActiveAndFocussed() + if !active { log.Warn().Msgf("string editor pane asked to process input despite view reporting not active; likely logic error") } return p.LeafPane.ProcessInput(k) @@ -84,7 +90,7 @@ func NewStringEditorPane( inputProcessor input.ModalInputProcessor, view views.StringEditorView, stylesheet styling.Stylesheet, - cursorController ui.TextCursorController, + cursorController ui.CursorLocationRequestHandler, ) *StringEditorPane { return &StringEditorPane{ LeafPane: ui.LeafPane{ @@ -99,5 +105,6 @@ func NewStringEditorPane( }, view: view, cursorController: cursorController, + idStr: "string-editor-pane-" + uuid.Must(uuid.NewRandom()).String(), } } diff --git a/internal/ui/ui.go b/internal/ui/ui.go index de19d5ac..796b1011 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -1,6 +1,8 @@ package ui import ( + "fmt" + "github.com/ja-he/dayplan/internal/input" "github.com/ja-he/dayplan/internal/styling" ) @@ -213,5 +215,34 @@ type MouseCursorPos struct { // TextCursorController offers control of a text cursor, such as for a terminal. type TextCursorController interface { HideCursor() - ShowCursor(x, y int) + ShowCursor(CursorLocation) +} + +type CursorLocation struct { + X int + Y int +} + +func (l CursorLocation) String() string { + return fmt.Sprintf("%d:%d", l.X, l.Y) +} + +type BoxRepresentation[T any] struct { + X int + Y int + W int + H int + + Represents T + + Children []BoxRepresentation[T] +} + +func (r *BoxRepresentation[T]) String() string { + str := fmt.Sprintf("+%d+%d %dx%d [ ", r.X, r.Y, r.W, r.H) + for _, child := range r.Children { + str += child.String() + } + str += "]" + return str }