Skip to content

Commit

Permalink
refactor/fix: Refactor editor construction and status handling
Browse files Browse the repository at this point in the history
To be quite honest, this is a chunky commit because I took a stab at
this a few times with big breaks in between. I remember starting
work on some part of this on the plane from South America to Europe
a while ago.

Basically, this fixes a bunch of outstanding design issues, finally.

The following is an autogenerated commit summary:

- Replaced `IsActiveAndFocussed` with `GetStatus` in `Editor` interface.
- Introduced `EditorStatus` type and constants to represent editor states.
- Updated `Composite` and `StringEditor` to use new `GetStatus` method.
- Modified `Composite` to manage fields with a map of EditorID to Editor.
- Adjusted `NewCompositeEditorPane` and `StringEditorPane` to handle new status logic.
- Improved logging and error handling for editor construction and status checks.
  • Loading branch information
ja-he committed Jul 1, 2024
1 parent dc4ade5 commit ab30a22
Show file tree
Hide file tree
Showing 6 changed files with 254 additions and 110 deletions.
4 changes: 2 additions & 2 deletions internal/control/cli/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,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
Expand Down Expand Up @@ -745,7 +745,7 @@ func NewController(
log.Warn().Msgf("was about to construct new event editor but still have old one")
return
}
newEventEditor, err := editors.ConstructEditor("event", event, nil, func() (bool, bool) { return true, true })
newEventEditor, err := editors.ConstructEditor("event", event, nil, nil)
if err != nil {
log.Warn().Err(err).Msgf("unable to construct event editor")
return
Expand Down
40 changes: 39 additions & 1 deletion internal/control/edit/editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ package edit

// Editor is an interface for editing of objects (by the user).
type Editor interface {
IsActiveAndFocussed() (bool, bool)

// GetStatus informs on whether the editor is active and
// selected and focussed.
GetStatus() EditorStatus

GetName() string

Expand All @@ -19,3 +22,38 @@ type Editor interface {
// AddQuitCallback adds a callback that is called when the editor is quit.
AddQuitCallback(func())
}

// 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"
)
182 changes: 128 additions & 54 deletions internal/control/edit/editors/composite_editor.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,36 +12,56 @@ 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"
)

// 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].GetName(), e.fields[nextID].GetName())
// 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.
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].GetName(), e.fields[e.fieldOrder[nextIndex]].GetName())
e.activeFieldID = e.fieldOrder[nextIndex]
}

// EnterField changes the editor to enter the currently selected field, e.g.
Expand All @@ -54,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 {
Expand All @@ -69,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
Expand All @@ -86,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":
Expand All @@ -101,76 +123,125 @@ 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{
Name: 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) // TODO: no clue what i was smoking here...
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 e, nil
return constructedCompositeEditor, 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 }
func (e *Composite) GetName() string { return e.id }

// Write writes the content of the editor back to the underlying data structure
// by calling the write functions of all subeditors.
Expand Down Expand Up @@ -230,12 +301,15 @@ func (e *Composite) CreateInputProcessor(cfg input.InputConfig) (input.ModalInpu
return processors.NewModalInputProcessor(inputTree), nil
}

func (e *Composite) GetActiveFieldIndex() int { return e.activeFieldIndex }
func (e *Composite) IsInField() bool { return e.inField }
// GetActiveFieldID returns the ID of the currently active field.
func (e *Composite) GetActiveFieldID() EditorID { return e.activeFieldID }

func (e *Composite) IsActiveAndFocussed() (bool, bool) { return e.activeAndFocussedFunc() }
// 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.
func (e *Composite) GetFields() []edit.Editor {
//
// TOOD: should this exist / be public (what is it good for)?
func (e *Composite) GetFields() map[EditorID]edit.Editor {
return e.fields
}
Loading

0 comments on commit ab30a22

Please sign in to comment.