diff --git a/pkg/cy/client.go b/pkg/cy/client.go index 04a8064b..0bc4c61f 100644 --- a/pkg/cy/client.go +++ b/pkg/cy/client.go @@ -12,6 +12,7 @@ import ( P "github.com/cfoust/cy/pkg/io/protocol" "github.com/cfoust/cy/pkg/janet" "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/engine" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/mux/screen" "github.com/cfoust/cy/pkg/mux/screen/server" @@ -54,7 +55,7 @@ type Client struct { params *params.Parameters muxClient *server.Client - layoutEngine *layout.LayoutEngine + layoutEngine *engine.LayoutEngine toast *ToastLogger toaster *taro.Program frame *frames.Framer @@ -237,11 +238,11 @@ func (c *Client) initialize(options ClientOptions) error { c.muxClient = c.cy.muxServer.AddClient(c.Ctx(), options.Size) - c.layoutEngine = layout.NewLayoutEngine( + c.layoutEngine = engine.New( c.Ctx(), c.cy.tree, c.cy.muxServer, - layout.WithParams(c.params), + engine.WithParams(c.params), ) err = c.layoutEngine.Set(layout.New(layout.MarginsType{ diff --git a/pkg/cy/stories.go b/pkg/cy/stories.go index 94400cb5..8ae97ba4 100644 --- a/pkg/cy/stories.go +++ b/pkg/cy/stories.go @@ -6,7 +6,7 @@ import ( "github.com/cfoust/cy/pkg/emu" "github.com/cfoust/cy/pkg/geom" - "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/split" "github.com/cfoust/cy/pkg/mux" S "github.com/cfoust/cy/pkg/mux/screen" "github.com/cfoust/cy/pkg/stories" @@ -234,7 +234,7 @@ func init() { return nil, err } - split := layout.NewSplit( + split := split.New( ctx, screenA, screenB, diff --git a/pkg/input/fuzzy/preview/layout.go b/pkg/input/fuzzy/preview/layout.go index 4124bdba..e786f473 100644 --- a/pkg/input/fuzzy/preview/layout.go +++ b/pkg/input/fuzzy/preview/layout.go @@ -4,6 +4,7 @@ import ( "context" "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/engine" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/mux/screen/server" "github.com/cfoust/cy/pkg/mux/screen/tree" @@ -19,7 +20,7 @@ func NewLayout( muxServer *server.Server, args LayoutType, ) mux.Screen { - l := layout.NewLayoutEngine( + l := engine.New( ctx, tree, muxServer, diff --git a/pkg/janet/interop.go b/pkg/janet/interop.go index 9c2e5158..ec5490de 100644 --- a/pkg/janet/interop.go +++ b/pkg/janet/interop.go @@ -356,6 +356,24 @@ func isNamable(type_ reflect.Type) bool { return getNamable(type_) != nil } +func isMarshalable(type_ reflect.Type) bool { + if type_.Kind() != reflect.Pointer { + return false + } + + _, ok := reflect.New(type_.Elem()).Interface().(Marshalable) + return ok +} + +func isUnmarshalable(type_ reflect.Type) bool { + if type_.Kind() != reflect.Pointer { + return false + } + + _, ok := reflect.New(type_.Elem()).Interface().(Unmarshalable) + return ok +} + func isParamType(type_ reflect.Type) bool { if type_.Kind() == reflect.Pointer && isValidType(type_.Elem()) { return true @@ -404,7 +422,7 @@ func validateFunction(in, out []reflect.Type) error { break } - if !isSpecial(argType) && !isParamType(argType) && !isInterface(argType) { + if !isSpecial(argType) && !isUnmarshalable(argType) && !isParamType(argType) && !isInterface(argType) { return fmt.Errorf( "arg %d's type %s (%s) not supported", i, @@ -423,13 +441,13 @@ func validateFunction(in, out []reflect.Type) error { // The first return value can be an error or valid type if numResults == 1 { first := out[0] - if !isParamType(first) && !isErrorType(first) && !isInterface(first) { + if !isParamType(first) && !isMarshalable(first) && !isErrorType(first) && !isInterface(first) { return fmt.Errorf("first callback return type must be valid type or error") } } if numResults == 2 { - if !isParamType(out[0]) && !isInterface(out[0]) { + if !isParamType(out[0]) && !isInterface(out[0]) && !isMarshalable(out[0]) { return fmt.Errorf("first callback return type must be valid type") } diff --git a/pkg/layout/borders.go b/pkg/layout/borders/module.go similarity index 79% rename from pkg/layout/borders.go rename to pkg/layout/borders/module.go index 66e03ccb..6cc1e445 100644 --- a/pkg/layout/borders.go +++ b/pkg/layout/borders/module.go @@ -1,10 +1,11 @@ -package layout +package borders import ( "context" "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/tty" + L "github.com/cfoust/cy/pkg/layout" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/style" "github.com/cfoust/cy/pkg/taro" @@ -26,10 +27,10 @@ type Borders struct { } var _ mux.Screen = (*Borders)(nil) -var _ reusable = (*Borders)(nil) +var _ L.Reusable = (*Borders)(nil) -func (l *Borders) reuse(node NodeType) (bool, error) { - config, ok := node.(BorderType) +func (l *Borders) Apply(node L.NodeType) (bool, error) { + config, ok := node.(L.BorderType) if !ok { return false, nil } @@ -130,7 +131,7 @@ func (l *Borders) poll(ctx context.Context) { case <-ctx.Done(): return case event := <-updates.Recv(): - if _, ok := event.(nodeChangeEvent); ok { + if _, ok := event.(L.NodeChangeEvent); ok { continue } l.Publish(event) @@ -170,38 +171,7 @@ func (l *Borders) Resize(size geom.Size) error { return l.recalculate() } -func (l *LayoutEngine) createBorders( - node *screenNode, - config BorderType, -) error { - innerNode, err := l.createNode( - node.Ctx(), - config.Node, - ) - if err != nil { - return err - } - - borders := NewBorders( - node.Ctx(), - innerNode.Screen, - ) - borders.borderStyle = config.Border - - if config.Title != nil { - borders.title = *config.Title - } - - if config.TitleBottom != nil { - borders.titleBottom = *config.TitleBottom - } - - node.Screen = borders - node.Children = []*screenNode{innerNode} - return nil -} - -func NewBorders(ctx context.Context, screen mux.Screen) *Borders { +func New(ctx context.Context, screen mux.Screen) *Borders { borders := &Borders{ UpdatePublisher: mux.NewPublisher(), size: geom.DEFAULT_SIZE, diff --git a/pkg/layout/engine/engine_test.go b/pkg/layout/engine/engine_test.go new file mode 100644 index 00000000..b84b5d09 --- /dev/null +++ b/pkg/layout/engine/engine_test.go @@ -0,0 +1,293 @@ +package engine + +import ( + "context" + "os" + "testing" + "time" + + "github.com/cfoust/cy/pkg/geom" + L "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/pane" + S "github.com/cfoust/cy/pkg/mux/screen" + "github.com/cfoust/cy/pkg/mux/screen/server" + T "github.com/cfoust/cy/pkg/mux/screen/tree" + "github.com/cfoust/cy/pkg/params" + "github.com/cfoust/cy/pkg/taro" + + "github.com/stretchr/testify/require" +) + +func TestSet(t *testing.T) { + l := New( + context.Background(), + T.NewTree(), + server.New(), + ) + + err := l.Set(L.New( + L.MarginsType{ + Node: L.SplitType{ + A: L.PaneType{Attached: true}, + B: L.PaneType{}, + }, + }, + )) + require.NoError(t, err) + + before := l.existing + err = l.Set(L.New( + L.MarginsType{ + Node: L.SplitType{ + A: L.PaneType{Attached: true}, + B: L.SplitType{ + A: L.PaneType{}, + B: L.PaneType{}, + }, + }, + }, + )) + require.NoError(t, err) + require.NotEqual(t, before, l.existing) +} + +func TestClickInactivePane(t *testing.T) { + size := geom.DEFAULT_SIZE + l := New( + context.Background(), + T.NewTree(), + server.New(), + ) + l.Resize(size) + + err := l.Set(L.New( + L.SplitType{ + A: L.PaneType{Attached: true}, + B: L.PaneType{}, + }, + )) + require.NoError(t, err) + + l.Send(taro.MouseMsg{ + Vec2: geom.Vec2{ + R: 0, + C: 45, + }, + Type: taro.MousePress, + Button: taro.MouseLeft, + }) + time.Sleep(500 * time.Millisecond) + + require.Equal(t, L.SplitType{ + A: L.PaneType{}, + B: L.PaneType{Attached: true}, + }, l.Get().Root) +} + +func TestRemoveAttached(t *testing.T) { + require.Equal(t, + L.MarginsType{Node: L.PaneType{Attached: true}}, + L.RemoveAttached(L.SplitType{ + A: L.MarginsType{Node: L.PaneType{}}, + B: L.PaneType{Attached: true}, + }), + ) +} + +func TestAttachFirst(t *testing.T) { + require.Equal(t, + L.SplitType{ + A: L.MarginsType{Node: L.PaneType{Attached: true}}, + B: L.PaneType{}, + }, + L.AttachFirst(L.SplitType{ + A: L.MarginsType{Node: L.PaneType{}}, + B: L.PaneType{}, + }), + ) +} + +func TestPaneRemoval(t *testing.T) { + ctx := context.Background() + size := geom.DEFAULT_SIZE + tree := T.NewTree() + params := params.New() + l := New( + ctx, + tree, + server.New(), + WithParams(params), + ) + l.Resize(size) + + createPane := func() (*taro.Program, *T.Pane, *T.NodeID) { + static := pane.NewStatic( + ctx, + false, + "foo", + ) + pane := tree.Root().NewPane(ctx, static) + id := pane.Id() + return static, pane, &id + } + + // Suffice it to say that I'm not happy about this, but it turns out + // that there are a lot of sensitive timing issues here and fixing this + // with a bunch of channels shoved into the LayoutEngine added a lot of + // complexity. It's not only a matter of waiting for the layout to be + // updated; you must also ensure that all nodes all the way down the + // tree have all started the goroutines they need to function. Worth a + // revisit someday if this is still flaky. + sleepDuration := 500 * time.Millisecond + if _, ok := os.LookupEnv("CI"); ok { + sleepDuration = 2 * time.Second + } + sleep := func() { + time.Sleep(sleepDuration) + } + + // First we just test killing the tree nodes + { + _, pane1, id1 := createPane() + _, pane2, id2 := createPane() + + removeOnExit := true + err := l.Set(L.New( + L.SplitType{ + A: L.PaneType{ + Attached: true, + ID: id1, + }, + B: L.PaneType{ + ID: id2, + RemoveOnExit: &removeOnExit, + }, + }, + )) + require.NoError(t, err) + + sleep() + pane1.Cancel() + sleep() + + // The attached node should stick around + require.Equal(t, L.SplitType{ + A: L.PaneType{ + Attached: true, + ID: nil, + }, + B: L.PaneType{ + ID: id2, + RemoveOnExit: &removeOnExit, + }, + }, l.Get().Root) + + // Switch attachment to B + require.NoError(t, l.Set(L.New( + L.SplitType{ + A: L.PaneType{ + ID: nil, + }, + B: L.PaneType{ + Attached: true, + ID: id2, + RemoveOnExit: &removeOnExit, + }, + }, + ))) + + // This pane should not stick around + pane2.Cancel() + sleep() + require.Equal(t, L.PaneType{ + Attached: true, + ID: nil, + }, l.Get().Root) + } + + // Next we test exiting the panes themselves + { + static1, _, id1 := createPane() + static2, _, id2 := createPane() + + removeOnExit := true + layout := L.SplitType{ + A: L.PaneType{ + Attached: true, + ID: id1, + }, + B: L.PaneType{ + ID: id2, + RemoveOnExit: &removeOnExit, + }, + } + + require.NoError(t, l.Set(L.New(layout))) + + // If static1 exits without an error and removeOnExit is not + // true, nothing should happen. + static1.Publish(S.ExitEvent{}) + sleep() + require.Equal(t, L.SplitType{ + A: L.PaneType{ + Attached: true, + ID: id1, + }, + B: L.PaneType{ + ID: id2, + RemoveOnExit: &removeOnExit, + }, + }, l.Get().Root) + + // Reset the layout to force new subscriptions + require.NoError(t, l.Set(L.New(L.PaneType{Attached: true}))) + require.NoError(t, l.Set(L.New(layout))) + + // static1 exiting with an error should still cause nothing to happen + static1.Publish(S.ExitEvent{ + Errored: true, + }) + sleep() + require.Equal(t, L.SplitType{ + A: L.PaneType{ + Attached: true, + ID: id1, + }, + B: L.PaneType{ + ID: id2, + RemoveOnExit: &removeOnExit, + }, + }, l.Get().Root) + + layout = L.SplitType{ + A: L.PaneType{ + ID: id1, + }, + B: L.PaneType{ + Attached: true, + ID: id2, + RemoveOnExit: &removeOnExit, + }, + } + require.NoError(t, l.Set(L.New(L.PaneType{Attached: true}))) + require.NoError(t, l.Set(L.New(layout))) + sleep() + + // If static2 exits without an error, it should remove the node + static2.Publish(S.ExitEvent{}) + sleep() + require.Equal(t, L.PaneType{ + Attached: true, + ID: id1, + }, l.Get().Root) + + // Reset + require.NoError(t, l.Set(L.New(L.PaneType{Attached: true}))) + require.NoError(t, l.Set(L.New(layout))) + + // If static2 exits with an error, it should NOT remove the node + static2.Publish(S.ExitEvent{Errored: true}) + sleep() + require.Equal(t, layout, l.Get().Root) + } +} diff --git a/pkg/layout/engine.go b/pkg/layout/engine/module.go similarity index 81% rename from pkg/layout/engine.go rename to pkg/layout/engine/module.go index 683d7ea4..9529108a 100644 --- a/pkg/layout/engine.go +++ b/pkg/layout/engine/module.go @@ -1,4 +1,4 @@ -package layout +package engine import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/tty" + L "github.com/cfoust/cy/pkg/layout" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/mux/screen/server" "github.com/cfoust/cy/pkg/mux/screen/tree" @@ -18,24 +19,14 @@ import ( "github.com/sasha-s/go-deadlock" ) -// reusable is used to describe a Screen that has a configuration that can -// change. Often a Screen does not actually need to be created from scratch -// when the corresponding layout node changes; it can just be updated. The -// reuse function checks whether the Screen can be updated to match the new -// configuration and updates it if possible. -type reusable interface { - mux.Screen - reuse(NodeType) (bool, error) -} - // screenNode is a node in the tree of layout nodes that have already been // turned into Screens. It holds on to the Screen and the configuration that // was used to create it originally, along with references to this layout // node's children. type screenNode struct { util.Lifetime - Screen reusable - Config NodeType + Screen L.Reusable + Config L.NodeType Children []*screenNode } @@ -119,7 +110,7 @@ type LayoutEngine struct { server *server.Server size geom.Size - layout NodeType + layout L.NodeType layoutLifetime *util.Lifetime screen mux.Screen existing *screenNode @@ -196,7 +187,7 @@ func (l *LayoutEngine) Resize(size geom.Size) error { // indicates to the LayoutEngine that it should attach to it. func (l *LayoutEngine) handleChange( node *screenNode, - config NodeType, + config L.NodeType, ) error { l.Lock() defer l.Unlock() @@ -205,8 +196,8 @@ func (l *LayoutEngine) handleChange( // If the new configuration changes the attachment point, we need to // detach from whatever node we're currently attached to - if isAttached(config) { - layout = detach(layout) + if L.IsAttached(config) { + layout = L.Detach(layout) } layout = applyNodeChange( @@ -216,7 +207,7 @@ func (l *LayoutEngine) handleChange( config, ) - err := validateTree(layout) + err := L.ValidateTree(layout) if err != nil { return err } @@ -231,14 +222,14 @@ func (l *LayoutEngine) handleChange( func (l *LayoutEngine) removeAttached() error { l.Lock() defer l.Unlock() - return l.set(removeAttached(l.layout)) + return l.set(L.RemoveAttached(l.layout)) } // createNode takes a layout node and (recursively) creates a new screenNode // that corresponds to that layout node's configuration. func (l *LayoutEngine) createNode( ctx context.Context, - config NodeType, + config L.NodeType, ) (*screenNode, error) { nodeLifetime := util.NewLifetime(ctx) node := &screenNode{ @@ -248,13 +239,13 @@ func (l *LayoutEngine) createNode( var err error switch config := config.(type) { - case PaneType: + case L.PaneType: err = l.createPane(node, config) - case MarginsType: + case L.MarginsType: err = l.createMargins(node, config) - case SplitType: + case L.SplitType: err = l.createSplit(node, config) - case BorderType: + case L.BorderType: err = l.createBorders(node, config) default: err = fmt.Errorf("unimplemented screen") @@ -270,11 +261,11 @@ func (l *LayoutEngine) createNode( select { case msg := <-updates.Recv(): switch msg := msg.(type) { - case nodeChangeEvent: + case L.NodeChangeEvent: // TODO(cfoust): 07/30/24 error // handling l.handleChange(node, msg.Config) - case nodeRemoveEvent: + case L.NodeRemoveEvent: l.removeAttached() } case <-node.Ctx().Done(): @@ -287,7 +278,7 @@ func (l *LayoutEngine) createNode( } type updateNode struct { - Config NodeType + Config L.NodeType Node *screenNode } @@ -296,14 +287,14 @@ type updateNode struct { // if it is not. func (l *LayoutEngine) updateNode( ctx context.Context, - config NodeType, + config L.NodeType, current *screenNode, ) (*screenNode, error) { if current == nil { return l.createNode(ctx, config) } - canReuse, err := current.Screen.reuse(config) + canReuse, err := current.Screen.Apply(config) if err != nil { return nil, err } @@ -315,7 +306,7 @@ func (l *LayoutEngine) updateNode( var updates []updateNode switch node := config.(type) { - case SplitType: + case L.SplitType: updates = append(updates, updateNode{ Config: node.A, @@ -326,14 +317,14 @@ func (l *LayoutEngine) updateNode( Node: current.Children[1], }, ) - case MarginsType: + case L.MarginsType: updates = append(updates, updateNode{ Config: node.Node, Node: current.Children[0], }, ) - case BorderType: + case L.BorderType: updates = append(updates, updateNode{ Config: node.Node, @@ -371,7 +362,7 @@ func (l *LayoutEngine) updateNode( return current, nil } -func (l *LayoutEngine) set(layout NodeType) error { +func (l *LayoutEngine) set(layout L.NodeType) error { node, err := l.updateNode( l.Ctx(), layout, @@ -396,7 +387,7 @@ func (l *LayoutEngine) set(layout NodeType) error { select { case msg := <-updates.Recv(): switch msg := msg.(type) { - case nodeChangeEvent: + case L.NodeChangeEvent: // don't emit these default: l.throttle.Publish(msg) @@ -413,8 +404,8 @@ func (l *LayoutEngine) set(layout NodeType) error { // Set changes the Layout rendered by this LayoutEngine by reusing as many // existing Screens as it can. -func (l *LayoutEngine) Set(layout Layout) error { - err := validateTree(layout.root) +func (l *LayoutEngine) Set(layout L.Layout) error { + err := L.ValidateTree(layout.Root) if err != nil { return err } @@ -422,15 +413,15 @@ func (l *LayoutEngine) Set(layout Layout) error { l.Lock() defer l.Unlock() - return l.set(layout.root) + return l.set(layout.Root) } // Get gets the Layout this LayoutEngine is rendering. -func (l *LayoutEngine) Get() Layout { +func (l *LayoutEngine) Get() L.Layout { l.RLock() layout := l.layout l.RUnlock() - return New(layout) + return L.New(layout) } type Setting func(*LayoutEngine) @@ -441,7 +432,7 @@ func WithParams(params *params.Parameters) Setting { } } -func NewLayoutEngine( +func New( ctx context.Context, tree *tree.Tree, muxServer *server.Server, @@ -469,3 +460,52 @@ func NewLayoutEngine( return engine } + +// applyNodeChange replaces the configuration of the target node with +// newConfig. This is only used to allow nodes to change their own +// configurations in response to user input (for now, just mouse events.) +func applyNodeChange( + current, target *screenNode, + currentConfig, newConfig L.NodeType, +) L.NodeType { + if current == target { + return newConfig + } + + switch currentConfig := currentConfig.(type) { + case L.PaneType: + return currentConfig + case L.SplitType: + currentConfig.A = applyNodeChange( + current.Children[0], + target, + currentConfig.A, + newConfig, + ) + currentConfig.B = applyNodeChange( + current.Children[1], + target, + currentConfig.B, + newConfig, + ) + return currentConfig + case L.MarginsType: + currentConfig.Node = applyNodeChange( + current.Children[0], + target, + currentConfig.Node, + newConfig, + ) + return currentConfig + case L.BorderType: + currentConfig.Node = applyNodeChange( + current.Children[0], + target, + currentConfig.Node, + newConfig, + ) + return currentConfig + } + + return currentConfig +} diff --git a/pkg/layout/engine/nodes.go b/pkg/layout/engine/nodes.go new file mode 100644 index 00000000..fb4478e2 --- /dev/null +++ b/pkg/layout/engine/nodes.go @@ -0,0 +1,100 @@ +package engine + +import ( + L "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/borders" + "github.com/cfoust/cy/pkg/layout/margins" + "github.com/cfoust/cy/pkg/layout/pane" + "github.com/cfoust/cy/pkg/layout/split" +) + +func (l *LayoutEngine) createPane( + node *screenNode, + config L.PaneType, +) error { + pane := pane.New( + node.Ctx(), + l.tree, + l.server, + l.params, + ) + + pane.Apply(config) + node.Screen = pane + return nil +} + +func (l *LayoutEngine) createMargins( + node *screenNode, + config L.MarginsType, +) error { + marginsNode, err := l.createNode( + node.Ctx(), + config.Node, + ) + if err != nil { + return err + } + + margins := margins.New( + node.Ctx(), + marginsNode.Screen, + ) + + margins.Apply(config) + + node.Screen = margins + node.Children = []*screenNode{marginsNode} + return nil +} + +func (l *LayoutEngine) createSplit( + node *screenNode, + config L.SplitType, +) error { + nodeA, err := l.createNode(node.Ctx(), config.A) + if err != nil { + return err + } + nodeB, err := l.createNode(node.Ctx(), config.B) + if err != nil { + return err + } + + split := split.New( + node.Ctx(), + nodeA.Screen, + nodeB.Screen, + config.Vertical, + ) + + split.Apply(config) + + node.Screen = split + node.Children = []*screenNode{nodeA, nodeB} + return nil +} + +func (l *LayoutEngine) createBorders( + node *screenNode, + config L.BorderType, +) error { + innerNode, err := l.createNode( + node.Ctx(), + config.Node, + ) + if err != nil { + return err + } + + borders := borders.New( + node.Ctx(), + innerNode.Screen, + ) + + borders.Apply(config) + + node.Screen = borders + node.Children = []*screenNode{innerNode} + return nil +} diff --git a/pkg/layout/janet.go b/pkg/layout/janet.go index 9693d5a7..743841f0 100644 --- a/pkg/layout/janet.go +++ b/pkg/layout/janet.go @@ -204,12 +204,12 @@ func unmarshalNode(value *janet.Value) (NodeType, error) { var _ janet.Unmarshalable = (*Layout)(nil) func (l *Layout) UnmarshalJanet(value *janet.Value) (err error) { - l.root, err = unmarshalNode(value) + l.Root, err = unmarshalNode(value) if err != nil { return } - return validateTree(l.root) + return ValidateTree(l.Root) } var _ janet.Marshalable = (*Layout)(nil) @@ -292,5 +292,5 @@ func marshalNode(node NodeType) interface{} { } func (l *Layout) MarshalJanet() interface{} { - return marshalNode(l.root) + return marshalNode(l.Root) } diff --git a/pkg/layout/layout_test.go b/pkg/layout/layout_test.go index 8651bb9f..d74e6dfb 100644 --- a/pkg/layout/layout_test.go +++ b/pkg/layout/layout_test.go @@ -1,23 +1,13 @@ package layout import ( - "context" - "os" "testing" - "time" - - "github.com/cfoust/cy/pkg/geom" - S "github.com/cfoust/cy/pkg/mux/screen" - "github.com/cfoust/cy/pkg/mux/screen/server" - T "github.com/cfoust/cy/pkg/mux/screen/tree" - "github.com/cfoust/cy/pkg/params" - "github.com/cfoust/cy/pkg/taro" "github.com/stretchr/testify/require" ) func TestValidate(t *testing.T) { - require.Error(t, validateTree(SplitType{ + require.Error(t, ValidateTree(SplitType{ A: PaneType{ Attached: true, }, @@ -25,288 +15,14 @@ func TestValidate(t *testing.T) { Attached: true, }, })) - require.Error(t, validateTree(SplitType{ + require.Error(t, ValidateTree(SplitType{ A: PaneType{}, B: PaneType{}, })) - require.NoError(t, validateTree(SplitType{ + require.NoError(t, ValidateTree(SplitType{ A: PaneType{ Attached: true, }, B: PaneType{}, })) } - -func TestSet(t *testing.T) { - l := NewLayoutEngine( - context.Background(), - T.NewTree(), - server.New(), - ) - - err := l.Set(New( - MarginsType{ - Node: SplitType{ - A: PaneType{Attached: true}, - B: PaneType{}, - }, - }, - )) - require.NoError(t, err) - - before := l.existing - err = l.Set(New( - MarginsType{ - Node: SplitType{ - A: PaneType{Attached: true}, - B: SplitType{ - A: PaneType{}, - B: PaneType{}, - }, - }, - }, - )) - require.NoError(t, err) - require.NotEqual(t, before, l.existing) -} - -func TestClickInactivePane(t *testing.T) { - size := geom.DEFAULT_SIZE - l := NewLayoutEngine( - context.Background(), - T.NewTree(), - server.New(), - ) - l.Resize(size) - - err := l.Set(New( - SplitType{ - A: PaneType{Attached: true}, - B: PaneType{}, - }, - )) - require.NoError(t, err) - - l.Send(taro.MouseMsg{ - Vec2: geom.Vec2{ - R: 0, - C: 45, - }, - Type: taro.MousePress, - Button: taro.MouseLeft, - }) - time.Sleep(500 * time.Millisecond) - - require.Equal(t, SplitType{ - A: PaneType{}, - B: PaneType{Attached: true}, - }, l.Get().root) -} - -func TestRemoveAttached(t *testing.T) { - require.Equal(t, - MarginsType{Node: PaneType{Attached: true}}, - removeAttached(SplitType{ - A: MarginsType{Node: PaneType{}}, - B: PaneType{Attached: true}, - }), - ) -} - -func TestAttachFirst(t *testing.T) { - require.Equal(t, - SplitType{ - A: MarginsType{Node: PaneType{Attached: true}}, - B: PaneType{}, - }, - attachFirst(SplitType{ - A: MarginsType{Node: PaneType{}}, - B: PaneType{}, - }), - ) -} - -func TestPaneRemoval(t *testing.T) { - ctx := context.Background() - size := geom.DEFAULT_SIZE - tree := T.NewTree() - params := params.New() - l := NewLayoutEngine( - ctx, - tree, - server.New(), - WithParams(params), - ) - l.Resize(size) - - createPane := func() (*taro.Program, *T.Pane, *T.NodeID) { - static := NewStatic( - ctx, - false, - "foo", - ) - pane := tree.Root().NewPane(ctx, static) - id := pane.Id() - return static, pane, &id - } - - // Suffice it to say that I'm not happy about this, but it turns out - // that there are a lot of sensitive timing issues here and fixing this - // with a bunch of channels shoved into the LayoutEngine added a lot of - // complexity. It's not only a matter of waiting for the layout to be - // updated; you must also ensure that all nodes all the way down the - // tree have all started the goroutines they need to function. Worth a - // revisit someday if this is still flaky. - sleepDuration := 500 * time.Millisecond - if _, ok := os.LookupEnv("CI"); ok { - sleepDuration = 2 * time.Second - } - sleep := func() { - time.Sleep(sleepDuration) - } - - // First we just test killing the tree nodes - { - _, pane1, id1 := createPane() - _, pane2, id2 := createPane() - - removeOnExit := true - err := l.Set(New( - SplitType{ - A: PaneType{ - Attached: true, - ID: id1, - }, - B: PaneType{ - ID: id2, - RemoveOnExit: &removeOnExit, - }, - }, - )) - require.NoError(t, err) - - sleep() - pane1.Cancel() - sleep() - - // The attached node should stick around - require.Equal(t, SplitType{ - A: PaneType{ - Attached: true, - ID: nil, - }, - B: PaneType{ - ID: id2, - RemoveOnExit: &removeOnExit, - }, - }, l.Get().root) - - // Switch attachment to B - require.NoError(t, l.Set(New( - SplitType{ - A: PaneType{ - ID: nil, - }, - B: PaneType{ - Attached: true, - ID: id2, - RemoveOnExit: &removeOnExit, - }, - }, - ))) - - // This pane should not stick around - pane2.Cancel() - sleep() - require.Equal(t, PaneType{ - Attached: true, - ID: nil, - }, l.Get().root) - } - - // Next we test exiting the panes themselves - { - static1, _, id1 := createPane() - static2, _, id2 := createPane() - - removeOnExit := true - layout := SplitType{ - A: PaneType{ - Attached: true, - ID: id1, - }, - B: PaneType{ - ID: id2, - RemoveOnExit: &removeOnExit, - }, - } - - require.NoError(t, l.Set(New(layout))) - - // If static1 exits without an error and removeOnExit is not - // true, nothing should happen. - static1.Publish(S.ExitEvent{}) - sleep() - require.Equal(t, SplitType{ - A: PaneType{ - Attached: true, - ID: id1, - }, - B: PaneType{ - ID: id2, - RemoveOnExit: &removeOnExit, - }, - }, l.Get().root) - - // Reset the layout to force new subscriptions - require.NoError(t, l.Set(New(PaneType{Attached: true}))) - require.NoError(t, l.Set(New(layout))) - - // static1 exiting with an error should still cause nothing to happen - static1.Publish(S.ExitEvent{ - Errored: true, - }) - sleep() - require.Equal(t, SplitType{ - A: PaneType{ - Attached: true, - ID: id1, - }, - B: PaneType{ - ID: id2, - RemoveOnExit: &removeOnExit, - }, - }, l.Get().root) - - layout = SplitType{ - A: PaneType{ - ID: id1, - }, - B: PaneType{ - Attached: true, - ID: id2, - RemoveOnExit: &removeOnExit, - }, - } - require.NoError(t, l.Set(New(PaneType{Attached: true}))) - require.NoError(t, l.Set(New(layout))) - sleep() - - // If static2 exits without an error, it should remove the node - static2.Publish(S.ExitEvent{}) - sleep() - require.Equal(t, PaneType{ - Attached: true, - ID: id1, - }, l.Get().root) - - // Reset - require.NoError(t, l.Set(New(PaneType{Attached: true}))) - require.NoError(t, l.Set(New(layout))) - - // If static2 exits with an error, it should NOT remove the node - static2.Publish(S.ExitEvent{Errored: true}) - sleep() - require.Equal(t, layout, l.Get().root) - } -} diff --git a/pkg/layout/margins.go b/pkg/layout/margins/module.go similarity index 84% rename from pkg/layout/margins.go rename to pkg/layout/margins/module.go index 435e3ffd..b716f0e2 100644 --- a/pkg/layout/margins.go +++ b/pkg/layout/margins/module.go @@ -1,4 +1,4 @@ -package layout +package margins import ( "context" @@ -7,6 +7,7 @@ import ( "github.com/cfoust/cy/pkg/frames" "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/tty" + L "github.com/cfoust/cy/pkg/layout" "github.com/cfoust/cy/pkg/mux" S "github.com/cfoust/cy/pkg/mux/screen" "github.com/cfoust/cy/pkg/style" @@ -37,7 +38,7 @@ type Margins struct { } var _ mux.Screen = (*Margins)(nil) -var _ reusable = (*Margins)(nil) +var _ L.Reusable = (*Margins)(nil) func fitMargin(outer, margin int) int { if margin == 0 { @@ -59,8 +60,8 @@ func getSize(outer, desired int) int { return desired } -func (l *Margins) reuse(node NodeType) (bool, error) { - config, ok := node.(MarginsType) +func (l *Margins) Apply(node L.NodeType) (bool, error) { + config, ok := node.(L.MarginsType) if !ok { return false, nil } @@ -207,7 +208,7 @@ func (l *Margins) poll(ctx context.Context) { case <-ctx.Done(): return case event := <-updates.Recv(): - if _, ok := event.(nodeChangeEvent); ok { + if _, ok := event.(L.NodeChangeEvent); ok { continue } l.Publish(event) @@ -229,37 +230,7 @@ func (l *Margins) Resize(size geom.Size) error { return l.recalculate() } -func (l *LayoutEngine) createMargins( - node *screenNode, - config MarginsType, -) error { - marginsNode, err := l.createNode( - node.Ctx(), - config.Node, - ) - if err != nil { - return err - } - - margins := NewMargins( - node.Ctx(), - marginsNode.Screen, - ) - margins.setSize(geom.Size{ - R: config.Rows, - C: config.Cols, - }) - - if config.Border != nil { - margins.borderStyle = config.Border - } - - node.Screen = margins - node.Children = []*screenNode{marginsNode} - return nil -} - -func NewMargins(ctx context.Context, screen mux.Screen) *Margins { +func New(ctx context.Context, screen mux.Screen) *Margins { margins := &Margins{ UpdatePublisher: mux.NewPublisher(), size: geom.Size{ @@ -273,7 +244,7 @@ func NewMargins(ctx context.Context, screen mux.Screen) *Margins { return margins } -func AddMargins(ctx context.Context, screen mux.Screen) mux.Screen { +func Add(ctx context.Context, screen mux.Screen) mux.Screen { innerLayers := S.NewLayers() innerLayers.NewLayer( ctx, @@ -282,7 +253,7 @@ func AddMargins(ctx context.Context, screen mux.Screen) mux.Screen { S.WithOpaque, S.WithInteractive, ) - margins := NewMargins(ctx, innerLayers) + margins := New(ctx, innerLayers) outerLayers := S.NewLayers() frame := frames.NewFramer(ctx, frames.RandomFrame()) diff --git a/pkg/layout/module.go b/pkg/layout/module.go index 1d9ce301..1725ec29 100644 --- a/pkg/layout/module.go +++ b/pkg/layout/module.go @@ -3,6 +3,7 @@ package layout import ( "fmt" + "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/mux/screen/tree" "github.com/cfoust/cy/pkg/style" ) @@ -40,18 +41,18 @@ type BorderType struct { } type Layout struct { - root NodeType + Root NodeType } func New(node NodeType) Layout { - return Layout{root: node} + return Layout{Root: node} } -type nodeChangeEvent struct { +type NodeChangeEvent struct { Config NodeType } -type nodeRemoveEvent struct{} +type NodeRemoveEvent struct{} // getPaneType gets all of the panes that are descendants of the provided node, // in essence all of the leaf nodes. @@ -88,31 +89,31 @@ func getNumLeaves(node NodeType) int { return 0 } -// attachFirst attaches to the first node it can find. -func attachFirst(node NodeType) NodeType { +// AttachFirst attaches to the first node it can find. +func AttachFirst(node NodeType) NodeType { switch node := node.(type) { case PaneType: node.Attached = true return node case SplitType: - node.A = attachFirst(node.A) + node.A = AttachFirst(node.A) return node case MarginsType: - node.Node = attachFirst(node.Node) + node.Node = AttachFirst(node.Node) return node case BorderType: - node.Node = attachFirst(node.Node) + node.Node = AttachFirst(node.Node) return node } return node } -// removeAttached removes the attached node by replacing its nearest parent +// RemoveAttached removes the attached node by replacing its nearest parent // that has more than one child with a parent with that child removed, or the // other child if there are no other children. -func removeAttached(node NodeType) NodeType { - if !isAttached(node) { +func RemoveAttached(node NodeType) NodeType { + if !IsAttached(node) { return node } @@ -120,108 +121,59 @@ func removeAttached(node NodeType) NodeType { case PaneType: return node case SplitType: - if isAttached(node.A) && getNumLeaves(node.A) == 1 { - return attachFirst(node.B) + if IsAttached(node.A) && getNumLeaves(node.A) == 1 { + return AttachFirst(node.B) } - if isAttached(node.B) && getNumLeaves(node.B) == 1 { - return attachFirst(node.A) + if IsAttached(node.B) && getNumLeaves(node.B) == 1 { + return AttachFirst(node.A) } - node.A = removeAttached(node.A) - node.B = removeAttached(node.B) + node.A = RemoveAttached(node.A) + node.B = RemoveAttached(node.B) return node case MarginsType: - node.Node = removeAttached(node.Node) + node.Node = RemoveAttached(node.Node) return node case BorderType: - node.Node = removeAttached(node.Node) + node.Node = RemoveAttached(node.Node) return node } return node } -// applyNodeChange replaces the configuration of the target node with -// newConfig. This is only used to allow nodes to change their own -// configurations in response to user input (for now, just mouse events.) -func applyNodeChange( - current, target *screenNode, - currentConfig, newConfig NodeType, -) NodeType { - if current == target { - return newConfig - } - - switch currentConfig := currentConfig.(type) { - case PaneType: - return currentConfig - case SplitType: - currentConfig.A = applyNodeChange( - current.Children[0], - target, - currentConfig.A, - newConfig, - ) - currentConfig.B = applyNodeChange( - current.Children[1], - target, - currentConfig.B, - newConfig, - ) - return currentConfig - case MarginsType: - currentConfig.Node = applyNodeChange( - current.Children[0], - target, - currentConfig.Node, - newConfig, - ) - return currentConfig - case BorderType: - currentConfig.Node = applyNodeChange( - current.Children[0], - target, - currentConfig.Node, - newConfig, - ) - return currentConfig - } - - return currentConfig -} - -// isAttached reports whether the node provided leads to a node that is +// IsAttached reports whether the node provided leads to a node that is // attached. -func isAttached(tree NodeType) bool { +func IsAttached(tree NodeType) bool { switch node := tree.(type) { case PaneType: return node.Attached case SplitType: - return isAttached(node.A) || isAttached(node.B) + return IsAttached(node.A) || IsAttached(node.B) case MarginsType: - return isAttached(node.Node) + return IsAttached(node.Node) case BorderType: - return isAttached(node.Node) + return IsAttached(node.Node) } return false } -// detach returns a copy of node with no attachment points. -func detach(node NodeType) NodeType { +// Detach returns a copy of node with no attachment points. +func Detach(node NodeType) NodeType { switch node := node.(type) { case PaneType: node.Attached = false return node case SplitType: - node.A = detach(node.A) - node.B = detach(node.B) + node.A = Detach(node.A) + node.B = Detach(node.B) return node case MarginsType: - node.Node = detach(node.Node) + node.Node = Detach(node.Node) return node case BorderType: - node.Node = detach(node.Node) + node.Node = Detach(node.Node) return node } @@ -258,7 +210,7 @@ func attach(node NodeType, id tree.NodeID) NodeType { // Attach changes the currently attached tree node to the one specified by id. func Attach(layout Layout, id tree.NodeID) Layout { - return Layout{root: attach(layout.root, id)} + return Layout{Root: attach(layout.Root, id)} } func getAttached(node NodeType) *tree.NodeID { @@ -288,12 +240,12 @@ func getAttached(node NodeType) *tree.NodeID { // Attached returns the ID field of the attached pane in the layout. func Attached(layout Layout) *tree.NodeID { - return getAttached(layout.root) + return getAttached(layout.Root) } -// validateTree inspects a tree and ensures that it conforms to all relevant +// ValidateTree inspects a tree and ensures that it conforms to all relevant // constraints, namely there should only be one PaneType with Attached=true. -func validateTree(tree NodeType) error { +func ValidateTree(tree NodeType) error { numAttached := 0 for _, pane := range getPaneType(tree) { if pane.Attached != true { @@ -312,3 +264,13 @@ func validateTree(tree NodeType) error { return nil } + +// Reusable is used to describe a Screen that has a configuration that can +// change. Often a Screen does not actually need to be created from scratch +// when the corresponding layout node changes; it can just be updated. The +// reuse function checks whether the Screen can be updated to match the new +// configuration and updates it if possible. +type Reusable interface { + mux.Screen + Apply(NodeType) (bool, error) +} diff --git a/pkg/layout/pane.go b/pkg/layout/pane/module.go similarity index 88% rename from pkg/layout/pane.go rename to pkg/layout/pane/module.go index 12729a5c..972605c2 100644 --- a/pkg/layout/pane.go +++ b/pkg/layout/pane/module.go @@ -1,4 +1,4 @@ -package layout +package pane import ( "context" @@ -6,6 +6,7 @@ import ( "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/tty" + L "github.com/cfoust/cy/pkg/layout" "github.com/cfoust/cy/pkg/mux" S "github.com/cfoust/cy/pkg/mux/screen" "github.com/cfoust/cy/pkg/mux/screen/server" @@ -26,7 +27,7 @@ type Pane struct { tree *tree.Tree server *server.Server - config PaneType + config L.PaneType size geom.Size id *tree.NodeID @@ -39,7 +40,7 @@ type Pane struct { } var _ mux.Screen = (*Pane)(nil) -var _ reusable = (*Pane)(nil) +var _ L.Reusable = (*Pane)(nil) func (p *Pane) Send(msg mux.Msg) { p.RLock() @@ -71,7 +72,7 @@ func (p *Pane) Send(msg mux.Msg) { } p.config.Attached = true - p.Publish(nodeChangeEvent{ + p.Publish(L.NodeChangeEvent{ Config: p.config, }) } @@ -161,14 +162,14 @@ func (p *Pane) attach( // Remove the node from the tree if removeOnExit && isAttached { - p.Publish(nodeRemoveEvent{}) + p.Publish(L.NodeRemoveEvent{}) return } // Keep the pane around, just detach from it in the // layout newConfig.ID = nil - p.Publish(nodeChangeEvent{ + p.Publish(L.NodeChangeEvent{ Config: newConfig, }) } @@ -221,7 +222,7 @@ func (p *Pane) setID(id *tree.NodeID) error { continue } - p.Publish(nodeRemoveEvent{}) + p.Publish(L.NodeRemoveEvent{}) } } }() @@ -230,7 +231,7 @@ func (p *Pane) setID(id *tree.NodeID) error { return nil } -func (p *Pane) applyConfig(config PaneType) { +func (p *Pane) applyConfig(config L.PaneType) { p.config = config p.isAttached = config.Attached @@ -241,8 +242,8 @@ func (p *Pane) applyConfig(config PaneType) { } } -func (p *Pane) reuse(node NodeType) (bool, error) { - config, ok := node.(PaneType) +func (p *Pane) Apply(node L.NodeType) (bool, error) { + config, ok := node.(L.PaneType) if !ok { return false, nil } @@ -268,29 +269,7 @@ func (p *Pane) reuse(node NodeType) (bool, error) { return true, nil } -func (l *LayoutEngine) createPane( - node *screenNode, - config PaneType, -) error { - pane := NewPane( - node.Ctx(), - l.tree, - l.server, - l.params, - ) - - pane.applyConfig(config) - - err := pane.setID(config.ID) - if err != nil { - return err - } - - node.Screen = pane - return nil -} - -func NewPane( +func New( ctx context.Context, tree *tree.Tree, server *server.Server, diff --git a/pkg/layout/static.go b/pkg/layout/pane/static.go similarity index 99% rename from pkg/layout/static.go rename to pkg/layout/pane/static.go index fb41baac..9bffeef3 100644 --- a/pkg/layout/static.go +++ b/pkg/layout/pane/static.go @@ -1,4 +1,4 @@ -package layout +package pane import ( "context" diff --git a/pkg/layout/split.go b/pkg/layout/split/module.go similarity index 85% rename from pkg/layout/split.go rename to pkg/layout/split/module.go index 300ad5f4..8d079019 100644 --- a/pkg/layout/split.go +++ b/pkg/layout/split/module.go @@ -1,4 +1,4 @@ -package layout +package split import ( "context" @@ -6,6 +6,7 @@ import ( "github.com/cfoust/cy/pkg/geom" "github.com/cfoust/cy/pkg/geom/image" "github.com/cfoust/cy/pkg/geom/tty" + L "github.com/cfoust/cy/pkg/layout" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/style" "github.com/cfoust/cy/pkg/taro" @@ -44,7 +45,7 @@ type Split struct { } var _ mux.Screen = (*Split)(nil) -var _ reusable = (*Split)(nil) +var _ L.Reusable = (*Split)(nil) func (s *Split) Kill() { s.RLock() @@ -108,8 +109,8 @@ func (s *Split) State() *tty.State { return state } -func (s *Split) reuse(node NodeType) (bool, error) { - config, ok := node.(SplitType) +func (s *Split) Apply(node L.NodeType) (bool, error) { + config, ok := node.(L.SplitType) if !ok { return false, nil } @@ -163,12 +164,12 @@ func (s *Split) poll(ctx context.Context) { case <-ctx.Done(): return case event := <-updatesA.Recv(): - if _, ok := event.(nodeChangeEvent); ok { + if _, ok := event.(L.NodeChangeEvent); ok { continue } s.Publish(event) case event := <-updatesB.Recv(): - if _, ok := event.(nodeChangeEvent); ok { + if _, ok := event.(L.NodeChangeEvent); ok { continue } s.Publish(event) @@ -255,44 +256,7 @@ func (s *Split) Resize(size geom.Size) error { return s.recalculate() } -func (l *LayoutEngine) createSplit( - node *screenNode, - config SplitType, -) error { - nodeA, err := l.createNode(node.Ctx(), config.A) - if err != nil { - return err - } - nodeB, err := l.createNode(node.Ctx(), config.B) - if err != nil { - return err - } - - split := NewSplit( - node.Ctx(), - nodeA.Screen, - nodeB.Screen, - config.Vertical, - ) - - if config.Percent != nil { - split.setPercent(*config.Percent) - } - - if config.Cells != nil { - split.setCells(*config.Cells) - } - - if config.Border != nil { - split.borderStyle = config.Border - } - - node.Screen = split - node.Children = []*screenNode{nodeA, nodeB} - return nil -} - -func NewSplit( +func New( ctx context.Context, screenA, screenB mux.Screen, isVertical bool, diff --git a/pkg/mux/screen/placeholder/stories.go b/pkg/mux/screen/placeholder/stories.go index 3b342d65..caf5ddd2 100644 --- a/pkg/mux/screen/placeholder/stories.go +++ b/pkg/mux/screen/placeholder/stories.go @@ -3,13 +3,13 @@ package placeholder import ( "context" - "github.com/cfoust/cy/pkg/layout" + "github.com/cfoust/cy/pkg/layout/margins" "github.com/cfoust/cy/pkg/mux" "github.com/cfoust/cy/pkg/stories" ) var Placeholder stories.InitFunc = func(ctx context.Context) (mux.Screen, error) { - return layout.AddMargins(ctx, New(ctx)), nil + return margins.Add(ctx, New(ctx)), nil } func init() {