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
+}