From 74e4fe2560a260079c3803a8b8c13bed3d678378 Mon Sep 17 00:00:00 2001 From: Alex Goodman <wagoodman@gmail.com> Date: Sun, 13 Oct 2019 20:56:58 -0400 Subject: [PATCH] decouple views with listeners --- runtime/ui/{ui.go => app.go} | 9 +- runtime/ui/controller.go | 212 ++++++++++++++++++++ runtime/ui/controller/collection.go | 166 --------------- runtime/ui/controller/controller.go | 16 -- runtime/ui/key/binding.go | 1 - runtime/ui/layout_manager.go | 6 +- runtime/ui/view/cursor.go | 37 ++++ runtime/ui/{controller => view}/details.go | 35 ++-- runtime/ui/{controller => view}/filetree.go | 99 ++++++--- runtime/ui/{controller => view}/filter.go | 35 +++- runtime/ui/{controller => view}/layer.go | 64 +++--- runtime/ui/view/renderer.go | 9 + runtime/ui/{controller => view}/status.go | 20 +- runtime/ui/viewmodel/layer_selection.go | 10 + 14 files changed, 449 insertions(+), 270 deletions(-) rename runtime/ui/{ui.go => app.go} (92%) create mode 100644 runtime/ui/controller.go delete mode 100644 runtime/ui/controller/collection.go delete mode 100644 runtime/ui/controller/controller.go create mode 100644 runtime/ui/view/cursor.go rename runtime/ui/{controller => view}/details.go (82%) rename runtime/ui/{controller => view}/filetree.go (84%) rename runtime/ui/{controller => view}/filter.go (79%) rename runtime/ui/{controller => view}/layer.go (83%) create mode 100644 runtime/ui/view/renderer.go rename runtime/ui/{controller => view}/status.go (81%) create mode 100644 runtime/ui/viewmodel/layer_selection.go diff --git a/runtime/ui/ui.go b/runtime/ui/app.go similarity index 92% rename from runtime/ui/ui.go rename to runtime/ui/app.go index e0cced8..ec7f5ad 100644 --- a/runtime/ui/ui.go +++ b/runtime/ui/app.go @@ -2,7 +2,6 @@ package ui import ( "github.com/wagoodman/dive/dive/image" - "github.com/wagoodman/dive/runtime/ui/controller" "github.com/wagoodman/dive/runtime/ui/key" "sync" @@ -16,7 +15,7 @@ const debug = false // type global type app struct { gui *gocui.Gui - controllers *controller.Collection + controllers *Controller layout *layoutManager } @@ -28,10 +27,10 @@ var ( func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*app, error) { var err error once.Do(func() { - var theControls *controller.Collection + var theControls *Controller var globalHelpKeys []*key.Binding - theControls, err = controller.NewCollection(gui, analysis, cache) + theControls, err = NewCollection(gui, analysis, cache) if err != nil { return } @@ -110,7 +109,7 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeC var lastX, lastY int // quit is the gocui callback invoked when the user hits Ctrl+C -func (ui *app) quit() error { +func (a *app) quit() error { // profileObj.Stop() // onExit() diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go new file mode 100644 index 0000000..0eef2c2 --- /dev/null +++ b/runtime/ui/controller.go @@ -0,0 +1,212 @@ +package ui + +import ( + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" + "github.com/wagoodman/dive/runtime/ui/view" + "github.com/wagoodman/dive/runtime/ui/viewmodel" + "regexp" +) + +type Controller struct { + gui *gocui.Gui + Tree *view.FileTree + Layer *view.Layer + Status *view.Status + Filter *view.Filter + Details *view.Details + lookup map[string]view.Renderer +} + +func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*Controller, error) { + var err error + + controller := &Controller{ + gui: g, + } + controller.lookup = make(map[string]view.Renderer) + + controller.Layer, err = view.NewLayerView("layers", g, analysis.Layers) + if err != nil { + return nil, err + } + controller.lookup[controller.Layer.Name()] = controller.Layer + + treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0) + if err != nil { + return nil, err + } + controller.Tree, err = view.NewFileTreeView("filetree", g, treeStack, analysis.RefTrees, cache) + if err != nil { + return nil, err + } + controller.lookup[controller.Tree.Name()] = controller.Tree + + // layer view cursor down event should trigger an update in the file tree + controller.Layer.AddLayerChangeListener(controller.onLayerChange) + + controller.Status = view.NewStatusView("status", g) + controller.lookup[controller.Status.Name()] = controller.Status + // set the layer view as the first selected view + controller.Status.SetCurrentView(controller.Layer) + + // update the status pane when a filetree option is changed by the user + controller.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange) + + controller.Filter = view.NewFilterView("filter", g) + controller.lookup[controller.Filter.Name()] = controller.Filter + controller.Filter.AddFilterEditListener(controller.onFilterEdit) + + controller.Details = view.NewDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes) + controller.lookup[controller.Details.Name()] = controller.Details + + // propagate initial conditions to necessary views + err = controller.onLayerChange(viewmodel.LayerSelection{ + Layer: controller.Layer.CurrentLayer(), + BottomTreeStart: 0, + BottomTreeStop: 0, + TopTreeStart: 0, + TopTreeStop: 0, + }) + + if err != nil { + return nil, err + } + + return controller, nil +} + +func (c *Controller) onFileTreeViewOptionChange() error { + err := c.Status.Update() + if err != nil { + return err + } + return c.Status.Render() +} + +func (c *Controller) onFilterEdit(filter string) error { + var filterRegex *regexp.Regexp + var err error + + if len(filter) > 0 { + filterRegex, err = regexp.Compile(filter) + if err != nil { + return err + } + } + + c.Tree.SetFilterRegex(filterRegex) + + err = c.Tree.Update() + if err != nil { + return err + } + + return c.Tree.Render() +} + +func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error { + // update the details + c.Details.SetCurrentLayer(selection.Layer) + + // update the filetree + err := c.Tree.SetTree(selection.BottomTreeStart, selection.BottomTreeStop, selection.TopTreeStart, selection.TopTreeStop) + if err != nil { + return err + } + + if c.Layer.CompareMode == view.CompareAll { + c.Tree.SetTitle("Aggregated Layer Contents") + } else { + c.Tree.SetTitle("Current Layer Contents") + } + + // update details and filetree panes + return c.UpdateAndRender() +} + +func (c *Controller) UpdateAndRender() error { + err := c.Update() + if err != nil { + logrus.Debug("failed update: ", err) + return err + } + + err = c.Render() + if err != nil { + logrus.Debug("failed render: ", err) + return err + } + + return nil +} + +// Update refreshes the state objects for future rendering. +func (c *Controller) Update() error { + for _, controller := range c.lookup { + err := controller.Update() + if err != nil { + logrus.Debug("unable to update controller: ") + return err + } + } + return nil +} + +// Render flushes the state objects to the screen. +func (c *Controller) Render() error { + for _, controller := range c.lookup { + if controller.IsVisible() { + err := controller.Render() + if err != nil { + return err + } + } + } + return nil +} + +// ToggleView switches between the file view and the layer view and re-renders the screen. +func (c *Controller) ToggleView() (err error) { + v := c.gui.CurrentView() + if v == nil || v.Name() == c.Layer.Name() { + _, err = c.gui.SetCurrentView(c.Tree.Name()) + c.Status.SetCurrentView(c.Tree) + } else { + _, err = c.gui.SetCurrentView(c.Layer.Name()) + c.Status.SetCurrentView(c.Layer) + } + + if err != nil { + logrus.Error("unable to toggle view: ", err) + return err + } + + return c.UpdateAndRender() +} + +func (c *Controller) ToggleFilterView() error { + // delete all user input from the tree view + err := c.Filter.ToggleVisible() + if err != nil { + logrus.Error("unable to toggle filter visibility: ", err) + return err + } + + // we have just hidden the filter view... + if !c.Filter.IsVisible() { + // ...remove any filter from the tree + c.Tree.SetFilterRegex(nil) + + // ...adjust focus to a valid (visible) view + err = c.ToggleView() + if err != nil { + logrus.Error("unable to toggle filter view (back): ", err) + return err + } + } + + return c.UpdateAndRender() +} diff --git a/runtime/ui/controller/collection.go b/runtime/ui/controller/collection.go deleted file mode 100644 index a7c6124..0000000 --- a/runtime/ui/controller/collection.go +++ /dev/null @@ -1,166 +0,0 @@ -package controller - -import ( - "errors" - "github.com/jroimartin/gocui" - "github.com/sirupsen/logrus" - "github.com/wagoodman/dive/dive/filetree" - "github.com/wagoodman/dive/dive/image" -) - -// var ccOnce sync.Once -var controllers *Collection - -type Collection struct { - gui *gocui.Gui - Tree *FileTree - Layer *Layer - Status *Status - Filter *Filter - Details *Details - lookup map[string]Controller -} - -func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.TreeCache) (*Collection, error) { - var err error - - controllers = &Collection{ - gui: g, - } - controllers.lookup = make(map[string]Controller) - - controllers.Layer, err = NewLayerController("layers", g, analysis.Layers) - if err != nil { - return nil, err - } - controllers.lookup[controllers.Layer.name] = controllers.Layer - - treeStack, err := filetree.StackTreeRange(analysis.RefTrees, 0, 0) - if err != nil { - return nil, err - } - controllers.Tree, err = NewFileTreeController("filetree", g, treeStack, analysis.RefTrees, cache) - if err != nil { - return nil, err - } - controllers.lookup[controllers.Tree.name] = controllers.Tree - - controllers.Status = NewStatusController("status", g) - controllers.lookup[controllers.Status.name] = controllers.Status - - controllers.Filter = NewFilterController("filter", g) - controllers.lookup[controllers.Filter.name] = controllers.Filter - - controllers.Details = NewDetailsController("details", g, analysis.Efficiency, analysis.Inefficiencies) - controllers.lookup[controllers.Details.name] = controllers.Details - return controllers, nil -} - -func (c *Collection) UpdateAndRender() error { - err := c.Update() - if err != nil { - logrus.Debug("failed update: ", err) - return err - } - - err = c.Render() - if err != nil { - logrus.Debug("failed render: ", err) - return err - } - - return nil -} - -// Update refreshes the state objects for future rendering. -func (c *Collection) Update() error { - for _, controller := range c.lookup { - err := controller.Update() - if err != nil { - logrus.Debug("unable to update controller: ") - return err - } - } - return nil -} - -// Render flushes the state objects to the screen. -func (c *Collection) Render() error { - for _, controller := range c.lookup { - if controller.IsVisible() { - err := controller.Render() - if err != nil { - return err - } - } - } - return nil -} - -// ToggleView switches between the file view and the layer view and re-renders the screen. -func (c *Collection) ToggleView() (err error) { - v := c.gui.CurrentView() - if v == nil || v.Name() == c.Layer.Name() { - _, err = c.gui.SetCurrentView(c.Tree.Name()) - } else { - _, err = c.gui.SetCurrentView(c.Layer.Name()) - } - - if err != nil { - logrus.Error("unable to toggle view: ", err) - return err - } - - return c.UpdateAndRender() -} - -func (c *Collection) ToggleFilterView() error { - // delete all user input from the tree view - err := c.Filter.ToggleVisible() - if err != nil { - logrus.Error("unable to toggle filter visibility: ", err) - return err - } - - // we have just hidden the filter view, adjust focus to a valid (visible) view - if !c.Filter.IsVisible() { - err = c.ToggleView() - if err != nil { - logrus.Error("unable to toggle filter view (back): ", err) - return err - } - } - - return c.UpdateAndRender() -} - -// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. -func (c *Collection) CursorDown(g *gocui.Gui, v *gocui.View) error { - return c.CursorStep(g, v, 1) -} - -// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. -func (c *Collection) CursorUp(g *gocui.Gui, v *gocui.View) error { - return c.CursorStep(g, v, -1) -} - -// Moves the cursor the given step distance, setting the origin to the new cursor line -func (c *Collection) CursorStep(g *gocui.Gui, v *gocui.View, step int) error { - cx, cy := v.Cursor() - - // if there isn't a next line - line, err := v.Line(cy + step) - if err != nil { - return err - } - if len(line) == 0 { - return errors.New("unable to move the cursor, empty line") - } - if err := v.SetCursor(cx, cy+step); err != nil { - ox, oy := v.Origin() - if err := v.SetOrigin(ox, oy+step); err != nil { - return err - } - } - return nil -} diff --git a/runtime/ui/controller/controller.go b/runtime/ui/controller/controller.go deleted file mode 100644 index 861b43f..0000000 --- a/runtime/ui/controller/controller.go +++ /dev/null @@ -1,16 +0,0 @@ -package controller - -import ( - "github.com/jroimartin/gocui" -) - -// Controller defines the a renderable terminal screen pane. -type Controller interface { - Update() error - Render() error - Setup(*gocui.View, *gocui.View) error - CursorDown() error - CursorUp() error - KeyHelp() string - IsVisible() bool -} diff --git a/runtime/ui/key/binding.go b/runtime/ui/key/binding.go index 777c047..40533b4 100644 --- a/runtime/ui/key/binding.go +++ b/runtime/ui/key/binding.go @@ -83,7 +83,6 @@ func newBinding(gui *gocui.Gui, influence string, keys []keybinding.Key, display } for _, key := range keys { - logrus.Debugf("registering %d %d (%+v)", key.Value, key.Modifier, key.Tokens) if err := gui.SetKeybinding(influence, key.Value, key.Modifier, binding.onAction); err != nil { return nil, err } diff --git a/runtime/ui/layout_manager.go b/runtime/ui/layout_manager.go index fb59d54..72f8c26 100644 --- a/runtime/ui/layout_manager.go +++ b/runtime/ui/layout_manager.go @@ -4,15 +4,15 @@ import ( "github.com/jroimartin/gocui" "github.com/sirupsen/logrus" "github.com/spf13/viper" - "github.com/wagoodman/dive/runtime/ui/controller" ) type layoutManager struct { fileTreeSplitRatio float64 - controllers *controller.Collection + controllers *Controller } -func newLayoutManager(c *controller.Collection) *layoutManager { +// todo: this needs a major refactor (derive layout from view obj info, which should not live here) +func newLayoutManager(c *Controller) *layoutManager { fileTreeSplitRatio := viper.GetFloat64("filetree.pane-width") if fileTreeSplitRatio >= 1 || fileTreeSplitRatio <= 0 { diff --git a/runtime/ui/view/cursor.go b/runtime/ui/view/cursor.go new file mode 100644 index 0000000..7cd5bcf --- /dev/null +++ b/runtime/ui/view/cursor.go @@ -0,0 +1,37 @@ +package view + +import ( + "errors" + "github.com/jroimartin/gocui" +) + +// CursorDown moves the cursor down in the currently selected gocui pane, scrolling the screen as needed. +func CursorDown(g *gocui.Gui, v *gocui.View) error { + return CursorStep(g, v, 1) +} + +// CursorUp moves the cursor up in the currently selected gocui pane, scrolling the screen as needed. +func CursorUp(g *gocui.Gui, v *gocui.View) error { + return CursorStep(g, v, -1) +} + +// Moves the cursor the given step distance, setting the origin to the new cursor line +func CursorStep(g *gocui.Gui, v *gocui.View, step int) error { + cx, cy := v.Cursor() + + // if there isn't a next line + line, err := v.Line(cy + step) + if err != nil { + return err + } + if len(line) == 0 { + return errors.New("unable to move the cursor, empty line") + } + if err := v.SetCursor(cx, cy+step); err != nil { + ox, oy := v.Origin() + if err := v.SetOrigin(ox, oy+step); err != nil { + return err + } + } + return nil +} diff --git a/runtime/ui/controller/details.go b/runtime/ui/view/details.go similarity index 82% rename from runtime/ui/controller/details.go rename to runtime/ui/view/details.go index 60336e1..9cb5ce0 100644 --- a/runtime/ui/controller/details.go +++ b/runtime/ui/view/details.go @@ -1,9 +1,10 @@ -package controller +package view import ( "fmt" "github.com/sirupsen/logrus" "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" "strconv" @@ -23,10 +24,13 @@ type Details struct { header *gocui.View efficiency float64 inefficiencies filetree.EfficiencySlice + imageSize uint64 + + currentLayer *image.Layer } -// NewDetailsController creates a new view object attached the the global [gocui] screen object. -func NewDetailsController(name string, gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice) (controller *Details) { +// NewDetailsView creates a new view object attached the the global [gocui] screen object. +func NewDetailsView(name string, gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice, imageSize uint64) (controller *Details) { controller = new(Details) // populate main fields @@ -34,6 +38,7 @@ func NewDetailsController(name string, gui *gocui.Gui, efficiency float64, ineff controller.gui = gui controller.efficiency = efficiency controller.inefficiencies = inefficiencies + controller.imageSize = imageSize return controller } @@ -85,12 +90,12 @@ func (c *Details) IsVisible() bool { // CursorDown moves the cursor down in the details pane (currently indicates nothing). func (c *Details) CursorDown() error { - return controllers.CursorDown(c.gui, c.view) + return CursorDown(c.gui, c.view) } // CursorUp moves the cursor up in the details pane (currently indicates nothing). func (c *Details) CursorUp() error { - return controllers.CursorUp(c.gui, c.view) + return CursorUp(c.gui, c.view) } // Update refreshes the state objects for future rendering. @@ -98,13 +103,19 @@ func (c *Details) Update() error { return nil } +func (c *Details) SetCurrentLayer(layer *image.Layer) { + c.currentLayer = layer +} + // Render flushes the state objects to the screen. The details pane reports: // 1. the current selected layer's command string // 2. the image efficiency score // 3. the estimated wasted image space // 4. a list of inefficient file allocations func (c *Details) Render() error { - currentLayer := controllers.Layer.currentLayer() + if c.currentLayer == nil { + return fmt.Errorf("no layer selected") + } var wastedSpace int64 @@ -126,7 +137,7 @@ func (c *Details) Render() error { } } - imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(controllers.Layer.ImageSize)) + imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(c.imageSize)) effStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*c.efficiency)) wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) @@ -147,15 +158,15 @@ func (c *Details) Render() error { c.view.Clear() var lines = make([]string, 0) - if currentLayer.Names != nil && len(currentLayer.Names) > 0 { - lines = append(lines, format.Header("Tags: ")+strings.Join(currentLayer.Names, ", ")) + if c.currentLayer.Names != nil && len(c.currentLayer.Names) > 0 { + lines = append(lines, format.Header("Tags: ")+strings.Join(c.currentLayer.Names, ", ")) } else { lines = append(lines, format.Header("Tags: ")+"(none)") } - lines = append(lines, format.Header("Id: ")+currentLayer.Id) - lines = append(lines, format.Header("Digest: ")+currentLayer.Digest) + lines = append(lines, format.Header("Id: ")+c.currentLayer.Id) + lines = append(lines, format.Header("Digest: ")+c.currentLayer.Digest) lines = append(lines, format.Header("Command:")) - lines = append(lines, currentLayer.Command) + lines = append(lines, c.currentLayer.Command) lines = append(lines, "\n"+format.Header(vtclean.Clean(imageHeaderStr, false))) lines = append(lines, imageSizeStr) lines = append(lines, wastedSpaceStr) diff --git a/runtime/ui/controller/filetree.go b/runtime/ui/view/filetree.go similarity index 84% rename from runtime/ui/controller/filetree.go rename to runtime/ui/view/filetree.go index df10a6b..3185be4 100644 --- a/runtime/ui/controller/filetree.go +++ b/runtime/ui/view/filetree.go @@ -1,7 +1,8 @@ -package controller +package view import ( "fmt" + "github.com/sirupsen/logrus" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" "github.com/wagoodman/dive/runtime/ui/viewmodel" @@ -20,6 +21,8 @@ const ( type CompareType int +type ViewOptionChangeListener func() error + // FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that // shows selected layer or aggregate file ASCII tree. type FileTree struct { @@ -28,13 +31,19 @@ type FileTree struct { view *gocui.View header *gocui.View vm *viewmodel.FileTree + title string + + filterRegex *regexp.Regexp + + listeners []ViewOptionChangeListener helpKeys []*key.Binding } -// NewFileTreeController creates a new view object attached the the global [gocui] screen object. -func NewFileTreeController(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (controller *FileTree, err error) { +// NewFileTreeView creates a new view object attached the the global [gocui] screen object. +func NewFileTreeView(name string, gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.TreeCache) (controller *FileTree, err error) { controller = new(FileTree) + controller.listeners = make([]ViewOptionChangeListener, 0) // populate main fields controller.name = name @@ -47,6 +56,18 @@ func NewFileTreeController(name string, gui *gocui.Gui, tree *filetree.FileTree, return controller, err } +func (c *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) { + c.listeners = append(c.listeners, listener...) +} + +func (c *FileTree) SetTitle(title string) { + c.title = title +} + +func (c *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) { + c.filterRegex = filterRegex +} + func (c *FileTree) Name() string { return c.name } @@ -166,12 +187,11 @@ func (c *FileTree) resetCursor() { } // SetTreeByLayer populates the view model by stacking the indicated image layer file trees. -func (c *FileTree) setTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { +func (c *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { err := c.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) if err != nil { return err } - // controller.ResetCursor() _ = c.Update() return c.Render() @@ -201,7 +221,7 @@ func (c *FileTree) CursorUp() error { // CursorLeft moves the cursor up until we reach the Parent Node or top of the tree func (c *FileTree) CursorLeft() error { - err := c.vm.CursorLeft(filterRegex()) + err := c.vm.CursorLeft(c.filterRegex) if err != nil { return err } @@ -211,7 +231,7 @@ func (c *FileTree) CursorLeft() error { // CursorRight descends into directory expanding it if needed func (c *FileTree) CursorRight() error { - err := c.vm.CursorRight(filterRegex()) + err := c.vm.CursorRight(c.filterRegex) if err != nil { return err } @@ -244,7 +264,7 @@ func (c *FileTree) PageUp() error { // ToggleCollapse will collapse/expand the selected FileNode. func (c *FileTree) toggleCollapse() error { - err := c.vm.ToggleCollapse(filterRegex()) + err := c.vm.ToggleCollapse(c.filterRegex) if err != nil { return err } @@ -265,44 +285,61 @@ func (c *FileTree) toggleCollapseAll() error { return c.Render() } +func (c *FileTree) notifyOnViewOptionChangeListeners() error { + for _, listener := range c.listeners { + err := listener() + if err != nil { + logrus.Errorf("notifyOnViewOptionChangeListeners error: %+v", err) + return err + } + } + return nil +} + // ToggleAttributes will show/hide file attributes func (c *FileTree) toggleAttributes() error { err := c.vm.ToggleAttributes() if err != nil { return err } + + err = c.Update() + if err != nil { + return err + } + err = c.Render() + if err != nil { + return err + } + // we need to render the changes to the status pane as well (not just this contoller/view) - return controllers.UpdateAndRender() + return c.notifyOnViewOptionChangeListeners() } // ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. func (c *FileTree) toggleShowDiffType(diffType filetree.DiffType) error { c.vm.ToggleShowDiffType(diffType) - // we need to render the changes to the status pane as well (not just this contoller/view) - return controllers.UpdateAndRender() -} -// filterRegex will return a regular expression object to match the user's filter input. -func filterRegex() *regexp.Regexp { - if controllers.Filter == nil || controllers.Filter.view == nil { - return nil - } - filterString := strings.TrimSpace(controllers.Filter.view.Buffer()) - if len(filterString) == 0 { - return nil - } - - regex, err := regexp.Compile(filterString) + err := c.Update() if err != nil { - return nil + return err + } + err = c.Render() + if err != nil { + return err } - return regex + // we need to render the changes to the status pane as well (not just this contoller/view) + return c.notifyOnViewOptionChangeListeners() } // OnLayoutChange is called by the UI framework to inform the view-model of the new screen dimensions func (c *FileTree) OnLayoutChange(resized bool) error { - _ = c.Update() + err := c.Update() + if err != nil { + return err + } + if resized { return c.Render() } @@ -320,19 +357,15 @@ func (c *FileTree) Update() error { width, height = c.gui.Size() } // height should account for the header - return c.vm.Update(filterRegex(), width, height-1) + return c.vm.Update(c.filterRegex, width, height-1) } // Render flushes the state objects (file tree) to the pane. func (c *FileTree) Render() error { - title := "Current Layer Contents" - if controllers.Layer.CompareMode == CompareAll { - title = "Aggregated Layer Contents" - } - + title := c.title // indicate when selected if c.gui.CurrentView() == c.view { - title = "● " + title + title = "● " + c.title } c.gui.Update(func(g *gocui.Gui) error { diff --git a/runtime/ui/controller/filter.go b/runtime/ui/view/filter.go similarity index 79% rename from runtime/ui/controller/filter.go rename to runtime/ui/view/filter.go index 51c0e3b..e32957a 100644 --- a/runtime/ui/controller/filter.go +++ b/runtime/ui/view/filter.go @@ -1,12 +1,15 @@ -package controller +package view import ( "fmt" "github.com/jroimartin/gocui" "github.com/sirupsen/logrus" "github.com/wagoodman/dive/runtime/ui/format" + "strings" ) +type FilterEditListener func(string) error + // Filter holds the UI objects and data models for populating the bottom row. Specifically the pane that // allows the user to filter the file tree by path. type Filter struct { @@ -17,12 +20,16 @@ type Filter struct { headerStr string maxLength int hidden bool + + filterEditListeners []FilterEditListener } -// NewFilterController creates a new view object attached the the global [gocui] screen object. -func NewFilterController(name string, gui *gocui.Gui) (controller *Filter) { +// NewFilterView creates a new view object attached the the global [gocui] screen object. +func NewFilterView(name string, gui *gocui.Gui) (controller *Filter) { controller = new(Filter) + controller.filterEditListeners = make([]FilterEditListener, 0) + // populate main fields controller.name = name controller.gui = gui @@ -32,6 +39,10 @@ func NewFilterController(name string, gui *gocui.Gui) (controller *Filter) { return controller } +func (c *Filter) AddFilterEditListener(listener ...FilterEditListener) { + c.filterEditListeners = append(c.filterEditListeners, listener...) +} + func (c *Filter) Name() string { return c.name } @@ -70,7 +81,7 @@ func (c *Filter) ToggleVisible() error { logrus.Error("unable to toggle filter view: ", err) return err } - return controllers.UpdateAndRender() + return nil } // reset the cursor for the next time it is visible @@ -119,9 +130,19 @@ func (c *Filter) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: v.EditDelete(true) } - if controllers.Tree != nil { - _ = controllers.Tree.Update() - _ = controllers.Tree.Render() + + // notify listeners + c.notifyFilterEditListeners() +} + +func (c *Filter) notifyFilterEditListeners() { + currentValue := strings.TrimSpace(c.view.Buffer()) + for _, listener := range c.filterEditListeners { + err := listener(currentValue) + if err != nil { + // note: cannot propagate error from here since this is from the main gogui thread + logrus.Errorf("notifyFilterEditListeners: %+v", err) + } } } diff --git a/runtime/ui/controller/layer.go b/runtime/ui/view/layer.go similarity index 83% rename from runtime/ui/controller/layer.go rename to runtime/ui/view/layer.go index 948a0b1..5d3c678 100644 --- a/runtime/ui/controller/layer.go +++ b/runtime/ui/view/layer.go @@ -1,10 +1,11 @@ -package controller +package view import ( "fmt" "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" + "github.com/wagoodman/dive/runtime/ui/viewmodel" "strings" "github.com/jroimartin/gocui" @@ -13,6 +14,8 @@ import ( "github.com/spf13/viper" ) +type LayerChangeListener func(viewmodel.LayerSelection) error + // Layer holds the UI objects and data models for populating the lower-left pane. Specifically the pane that // shows the image layers and layer selector. type Layer struct { @@ -24,15 +27,18 @@ type Layer struct { Layers []*image.Layer CompareMode CompareType CompareStartIndex int - ImageSize uint64 + + listeners []LayerChangeListener helpKeys []*key.Binding } -// NewLayerController creates a new view object attached the the global [gocui] screen object. -func NewLayerController(name string, gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) { +// NewLayerView creates a new view object attached the the global [gocui] screen object. +func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err error) { controller = new(Layer) + controller.listeners = make([]LayerChangeListener, 0) + // populate main fields controller.name = name controller.gui = gui @@ -50,6 +56,29 @@ func NewLayerController(name string, gui *gocui.Gui, layers []*image.Layer) (con return controller, err } +func (c *Layer) AddLayerChangeListener(listener ...LayerChangeListener) { + c.listeners = append(c.listeners, listener...) +} + +func (c *Layer) notifyLayerChangeListeners() error { + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes() + selection := viewmodel.LayerSelection{ + Layer: c.CurrentLayer(), + BottomTreeStart: bottomTreeStart, + BottomTreeStop: bottomTreeStop, + TopTreeStart: topTreeStart, + TopTreeStop: topTreeStop, + } + for _, listener := range c.listeners { + err := listener(selection) + if err != nil { + logrus.Errorf("notifyLayerChangeListeners error: %+v", err) + return err + } + } + return nil +} + func (c *Layer) Name() string { return c.name } @@ -141,7 +170,7 @@ func (c *Layer) PageDown() error { } if step > 0 { - err := controllers.CursorStep(c.gui, c.view, step) + err := CursorStep(c.gui, c.view, step) if err == nil { return c.SetCursor(c.LayerIndex + step) } @@ -159,7 +188,7 @@ func (c *Layer) PageUp() error { } if step > 0 { - err := controllers.CursorStep(c.gui, c.view, -step) + err := CursorStep(c.gui, c.view, -step) if err == nil { return c.SetCursor(c.LayerIndex - step) } @@ -170,7 +199,7 @@ func (c *Layer) PageUp() error { // CursorDown moves the cursor down in the layer pane (selecting a higher layer). func (c *Layer) CursorDown() error { if c.LayerIndex < len(c.Layers) { - err := controllers.CursorDown(c.gui, c.view) + err := CursorDown(c.gui, c.view) if err == nil { return c.SetCursor(c.LayerIndex + 1) } @@ -181,7 +210,7 @@ func (c *Layer) CursorDown() error { // CursorUp moves the cursor up in the layer pane (selecting a lower layer). func (c *Layer) CursorUp() error { if c.LayerIndex > 0 { - err := controllers.CursorUp(c.gui, c.view) + err := CursorUp(c.gui, c.view) if err == nil { return c.SetCursor(c.LayerIndex - 1) } @@ -192,30 +221,23 @@ func (c *Layer) CursorUp() error { // SetCursor resets the cursor and orients the file tree view based on the given layer index. func (c *Layer) SetCursor(layer int) error { c.LayerIndex = layer - err := controllers.Tree.setTreeByLayer(c.getCompareIndexes()) + err := c.notifyLayerChangeListeners() if err != nil { return err } - _ = controllers.Details.Render() - return c.Render() } -// currentLayer returns the Layer object currently selected. -func (c *Layer) currentLayer() *image.Layer { +// CurrentLayer returns the Layer object currently selected. +func (c *Layer) CurrentLayer() *image.Layer { return c.Layers[c.LayerIndex] } // setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. func (c *Layer) setCompareMode(compareMode CompareType) error { c.CompareMode = compareMode - err := controllers.UpdateAndRender() - if err != nil { - logrus.Errorf("unable to set compare mode: %+v", err) - return err - } - return controllers.Tree.setTreeByLayer(c.getCompareIndexes()) + return c.notifyLayerChangeListeners() } // getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) @@ -254,10 +276,6 @@ func (c *Layer) renderCompareBar(layerIdx int) string { // Update refreshes the state objects for future rendering (currently does nothing). func (c *Layer) Update() error { - c.ImageSize = 0 - for idx := 0; idx < len(c.Layers); idx++ { - c.ImageSize += c.Layers[idx].Size - } return nil } diff --git a/runtime/ui/view/renderer.go b/runtime/ui/view/renderer.go new file mode 100644 index 0000000..c3fadf5 --- /dev/null +++ b/runtime/ui/view/renderer.go @@ -0,0 +1,9 @@ +package view + +// Controller defines the a renderable terminal screen pane. +type Renderer interface { + Update() error + Render() error + IsVisible() bool + KeyHelp() string +} diff --git a/runtime/ui/controller/status.go b/runtime/ui/view/status.go similarity index 81% rename from runtime/ui/controller/status.go rename to runtime/ui/view/status.go index 9a3e612..bdd0363 100644 --- a/runtime/ui/controller/status.go +++ b/runtime/ui/view/status.go @@ -1,4 +1,4 @@ -package controller +package view import ( "fmt" @@ -17,11 +17,13 @@ type Status struct { gui *gocui.Gui view *gocui.View + selectedView Renderer + helpKeys []*key.Binding } -// NewStatusController creates a new view object attached the the global [gocui] screen object. -func NewStatusController(name string, gui *gocui.Gui) (controller *Status) { +// NewStatusView creates a new view object attached the the global [gocui] screen object. +func NewStatusView(name string, gui *gocui.Gui) (controller *Status) { controller = new(Status) // populate main fields @@ -32,6 +34,10 @@ func NewStatusController(name string, gui *gocui.Gui) (controller *Status) { return controller } +func (c *Status) SetCurrentView(r Renderer) { + c.selectedView = r +} + func (c *Status) Name() string { return c.name } @@ -74,7 +80,13 @@ func (c *Status) Update() error { func (c *Status) Render() error { c.gui.Update(func(g *gocui.Gui) error { c.view.Clear() - _, err := fmt.Fprintln(c.view, c.KeyHelp()+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) + + var selectedHelp string + if c.selectedView != nil { + selectedHelp = c.selectedView.KeyHelp() + } + + _, err := fmt.Fprintln(c.view, c.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) if err != nil { logrus.Debug("unable to write to buffer: ", err) } diff --git a/runtime/ui/viewmodel/layer_selection.go b/runtime/ui/viewmodel/layer_selection.go new file mode 100644 index 0000000..47bc5e2 --- /dev/null +++ b/runtime/ui/viewmodel/layer_selection.go @@ -0,0 +1,10 @@ +package viewmodel + +import ( + "github.com/wagoodman/dive/dive/image" +) + +type LayerSelection struct { + Layer *image.Layer + BottomTreeStart, BottomTreeStop, TopTreeStart, TopTreeStop int +}