diff --git a/.circleci/config.yml b/.circleci/config.yml index 120a6058..e22c4371 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -22,7 +22,7 @@ jobs: - "/go/pkg/mod" - run: name: run static analysis - command: make ci-static-analyses + command: make ci-static-analysis run-tests: parameters: diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml index 1434f90b..af9f49f4 100644 --- a/.github/workflows/pipeline.yml +++ b/.github/workflows/pipeline.yml @@ -64,7 +64,7 @@ jobs: run: go get ./... - name: Linting, formatting, and other static code analyses - run: make ci-static-analyses + run: make ci-static-analysis - name: Build snapshot artifacts run: make ci-build-snapshot-packages diff --git a/Makefile b/Makefile index df988368..93fc6232 100644 --- a/Makefile +++ b/Makefile @@ -12,7 +12,7 @@ all: clean build ci-unit-test: go test -cover -v -race ./... -ci-static-analyses: +ci-static-analysis: grep -R 'const allowTestDataCapture = false' runtime/ui/viewmodel go vet ./... @! gofmt -s -l . 2>&1 | grep -vE '^\.git/' | grep -vE '^\.cache/' diff --git a/cmd/root.go b/cmd/root.go index 680632cf..149ec054 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -141,6 +141,10 @@ func initLogging() { log.SetLevel(level) log.Debug("Starting Dive...") + log.Debugf("config filepath: %s", viper.ConfigFileUsed()) + for k, v := range viper.AllSettings() { + log.Debug("config value: ", k, " : ", v) + } } // getCfgFile checks for config file in paths from xdg specs diff --git a/runtime/ui/app.go b/runtime/ui/app.go index 0d771166..c1b48a78 100644 --- a/runtime/ui/app.go +++ b/runtime/ui/app.go @@ -3,6 +3,8 @@ package ui import ( "github.com/wagoodman/dive/dive/image" "github.com/wagoodman/dive/runtime/ui/key" + "github.com/wagoodman/dive/runtime/ui/layout" + "github.com/wagoodman/dive/runtime/ui/layout/compound" "sync" "github.com/jroimartin/gocui" @@ -16,7 +18,7 @@ const debug = false type app struct { gui *gocui.Gui controllers *Controller - layout *layoutManager + layout *layout.Manager } var ( @@ -27,19 +29,28 @@ var ( func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*app, error) { var err error once.Do(func() { - var theControls *Controller + var controller *Controller var globalHelpKeys []*key.Binding - theControls, err = NewCollection(gui, analysis, cache) + controller, err = NewCollection(gui, analysis, cache) if err != nil { return } - lm := newLayoutManager(theControls) + // note: order matters when adding elements to the layout + lm := layout.NewManager() + lm.Add(controller.views.Status, layout.LocationFooter) + lm.Add(controller.views.Filter, layout.LocationFooter) + lm.Add(compound.NewLayerDetailsCompoundLayout(controller.views.Layer, controller.views.Details), layout.LocationColumn) + lm.Add(controller.views.Tree, layout.LocationColumn) + // todo: access this more programmatically + if debug { + lm.Add(controller.views.Debug, layout.LocationColumn) + } gui.Cursor = false //g.Mouse = true - gui.SetManagerFunc(lm.layout) + gui.SetManagerFunc(lm.Layout) // var profileObj = profile.Start(profile.CPUProfile, profile.ProfilePath("."), profile.NoShutdownHook) // @@ -49,7 +60,7 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa appSingleton = &app{ gui: gui, - controllers: theControls, + controllers: controller, layout: lm, } @@ -61,13 +72,13 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa }, { ConfigKeys: []string{"keybinding.toggle-view"}, - OnAction: theControls.ToggleView, + OnAction: controller.ToggleView, Display: "Switch view", }, { ConfigKeys: []string{"keybinding.filter-files"}, - OnAction: theControls.ToggleFilterView, - IsSelected: theControls.Filter.IsVisible, + OnAction: controller.ToggleFilterView, + IsSelected: controller.views.Filter.IsVisible, Display: "Filter", }, } @@ -77,10 +88,10 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa return } - theControls.Status.AddHelpKeys(globalHelpKeys...) + controller.views.Status.AddHelpKeys(globalHelpKeys...) // perform the first update and render now that all resources have been loaded - err = theControls.UpdateAndRender() + err = controller.UpdateAndRender() if err != nil { return } @@ -106,8 +117,6 @@ func newApp(gui *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Compa // } // } -var lastX, lastY int - // quit is the gocui callback invoked when the user hits Ctrl+C func (a *app) quit() error { diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go index 4a2fb70d..1a801dfc 100644 --- a/runtime/ui/controller.go +++ b/runtime/ui/controller.go @@ -11,61 +11,33 @@ import ( ) 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 + gui *gocui.Gui + views *view.Views } func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*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) + views, err := view.NewViews(g, analysis, cache) 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 - //} - treeStack := analysis.RefTrees[0] - controller.Tree, err = view.NewFileTreeView("filetree", g, treeStack, analysis.RefTrees, cache) - if err != nil { - return nil, err + controller := &Controller{ + gui: g, + views: views, } - 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) + controller.views.Layer.AddLayerChangeListener(controller.onLayerChange) // 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.views.Tree.AddViewOptionChangeListener(controller.onFileTreeViewOptionChange) - controller.Details = view.NewDetailsView("details", g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes) - controller.lookup[controller.Details.Name()] = controller.Details + // update the tree view while the user types into the filter view + controller.views.Filter.AddFilterEditListener(controller.onFilterEdit) // propagate initial conditions to necessary views err = controller.onLayerChange(viewmodel.LayerSelection{ - Layer: controller.Layer.CurrentLayer(), + Layer: controller.views.Layer.CurrentLayer(), BottomTreeStart: 0, BottomTreeStop: 0, TopTreeStart: 0, @@ -80,11 +52,11 @@ func NewCollection(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree. } func (c *Controller) onFileTreeViewOptionChange() error { - err := c.Status.Update() + err := c.views.Status.Update() if err != nil { return err } - return c.Status.Render() + return c.views.Status.Render() } func (c *Controller) onFilterEdit(filter string) error { @@ -98,30 +70,30 @@ func (c *Controller) onFilterEdit(filter string) error { } } - c.Tree.SetFilterRegex(filterRegex) + c.views.Tree.SetFilterRegex(filterRegex) - err = c.Tree.Update() + err = c.views.Tree.Update() if err != nil { return err } - return c.Tree.Render() + return c.views.Tree.Render() } func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error { // update the details - c.Details.SetCurrentLayer(selection.Layer) + c.views.Details.SetCurrentLayer(selection.Layer) // update the filetree - err := c.Tree.SetTree(selection.BottomTreeStart, selection.BottomTreeStop, selection.TopTreeStart, selection.TopTreeStop) + err := c.views.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") + if c.views.Layer.CompareMode == view.CompareAll { + c.views.Tree.SetTitle("Aggregated Layer Contents") } else { - c.Tree.SetTitle("Current Layer Contents") + c.views.Tree.SetTitle("Current Layer Contents") } // update details and filetree panes @@ -146,7 +118,7 @@ func (c *Controller) UpdateAndRender() error { // Update refreshes the state objects for future rendering. func (c *Controller) Update() error { - for _, controller := range c.lookup { + for _, controller := range c.views.All() { err := controller.Update() if err != nil { logrus.Debug("unable to update controller: ") @@ -158,7 +130,7 @@ func (c *Controller) Update() error { // Render flushes the state objects to the screen. func (c *Controller) Render() error { - for _, controller := range c.lookup { + for _, controller := range c.views.All() { if controller.IsVisible() { err := controller.Render() if err != nil { @@ -172,12 +144,12 @@ func (c *Controller) Render() error { // 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) + if v == nil || v.Name() == c.views.Layer.Name() { + _, err = c.gui.SetCurrentView(c.views.Tree.Name()) + c.views.Status.SetCurrentView(c.views.Tree) } else { - _, err = c.gui.SetCurrentView(c.Layer.Name()) - c.Status.SetCurrentView(c.Layer) + _, err = c.gui.SetCurrentView(c.views.Layer.Name()) + c.views.Status.SetCurrentView(c.views.Layer) } if err != nil { @@ -190,16 +162,16 @@ func (c *Controller) ToggleView() (err error) { func (c *Controller) ToggleFilterView() error { // delete all user input from the tree view - err := c.Filter.ToggleVisible() + err := c.views.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() { + if !c.views.Filter.IsVisible() { // ...remove any filter from the tree - c.Tree.SetFilterRegex(nil) + c.views.Tree.SetFilterRegex(nil) // ...adjust focus to a valid (visible) view err = c.ToggleView() diff --git a/runtime/ui/format/format.go b/runtime/ui/format/format.go index b3e891e8..0f549102 100644 --- a/runtime/ui/format/format.go +++ b/runtime/ui/format/format.go @@ -1,7 +1,39 @@ package format import ( + "fmt" "github.com/fatih/color" + "github.com/lunixbochs/vtclean" + "strings" +) + +const ( + //selectedLeftBracketStr = " " + //selectedRightBracketStr = " " + //selectedFillStr = " " + // + //leftBracketStr = "▏" + //rightBracketStr = "▕" + //fillStr = "─" + + //selectedLeftBracketStr = " " + //selectedRightBracketStr = " " + //selectedFillStr = "━" + // + //leftBracketStr = "▏" + //rightBracketStr = "▕" + //fillStr = "─" + + selectedLeftBracketStr = "┃" + selectedRightBracketStr = "┣" + selectedFillStr = "━" + + leftBracketStr = "│" + rightBracketStr = "├" + fillStr = "─" + + selectStr = " ● " + //selectStr = " " ) var ( @@ -26,6 +58,19 @@ func init() { CompareBottom = color.New(color.BgGreen).SprintFunc() } +func RenderHeader(title string, width int, selected bool) string { + if selected { + body := Header(fmt.Sprintf("%s%s ", selectStr, title)) + bodyLen := len(vtclean.Clean(body, false)) + return fmt.Sprintf("%s%s%s%s\n", selectedLeftBracketStr, body, selectedRightBracketStr, strings.Repeat(selectedFillStr, width-bodyLen-2)) + //return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), Selected(strings.Repeat(selectedFillStr, width-bodyLen-2))) + //return fmt.Sprintf("%s%s%s%s\n", Selected(selectedLeftBracketStr), body, Selected(selectedRightBracketStr), strings.Repeat(selectedFillStr, width-bodyLen-2)) + } + body := Header(fmt.Sprintf(" %s ", title)) + bodyLen := len(vtclean.Clean(body, false)) + return fmt.Sprintf("%s%s%s%s\n", leftBracketStr, body, rightBracketStr, strings.Repeat(fillStr, width-bodyLen-2)) +} + func RenderHelpKey(control, title string, selected bool) string { if selected { return StatusSelected("▏") + StatusControlSelected(control) + StatusSelected(" "+title+" ") diff --git a/runtime/ui/layout/area.go b/runtime/ui/layout/area.go new file mode 100644 index 00000000..497534b4 --- /dev/null +++ b/runtime/ui/layout/area.go @@ -0,0 +1,5 @@ +package layout + +type Area struct { + minX, minY, maxX, maxY int +} diff --git a/runtime/ui/layout/compound/layer_details_column.go b/runtime/ui/layout/compound/layer_details_column.go new file mode 100644 index 00000000..754b6f03 --- /dev/null +++ b/runtime/ui/layout/compound/layer_details_column.go @@ -0,0 +1,107 @@ +package compound + +import ( + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/runtime/ui/view" + "github.com/wagoodman/dive/utils" +) + +type LayerDetailsCompoundLayout struct { + layer *view.Layer + details *view.Details +} + +func NewLayerDetailsCompoundLayout(layer *view.Layer, details *view.Details) *LayerDetailsCompoundLayout { + return &LayerDetailsCompoundLayout{ + layer: layer, + details: details, + } +} + +func (cl *LayerDetailsCompoundLayout) Name() string { + return "layer-details-compound-column" +} + +// OnLayoutChange is called whenever the screen dimensions are changed +func (cl *LayerDetailsCompoundLayout) OnLayoutChange() error { + err := cl.layer.OnLayoutChange() + if err != nil { + logrus.Error("unable to setup layer controller onLayoutChange", err) + return err + } + + err = cl.details.OnLayoutChange() + if err != nil { + logrus.Error("unable to setup details controller onLayoutChange", err) + return err + } + return nil +} + +func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, cl.Name()) + + //////////////////////////////////////////////////////////////////////////////////// + // Layers View + + // header + border + layerHeaderHeight := 2 + + layersHeight := len(cl.layer.Layers) + layerHeaderHeight + 1 // layers + header + base image layer row + maxLayerHeight := int(0.75 * float64(maxY)) + if layersHeight > maxLayerHeight { + layersHeight = maxLayerHeight + } + + // note: maxY needs to account for the (invisible) border, thus a +1 + header, headerErr := g.SetView(cl.layer.Name()+"header", minX, minY, maxX, minY+layerHeaderHeight+1) + + // we are going to overlap the view over the (invisible) border (so minY will be one less than expected) + main, viewErr := g.SetView(cl.layer.Name(), minX, minY+layerHeaderHeight, maxX, minY+layerHeaderHeight+layersHeight) + + if utils.IsNewView(viewErr, headerErr) { + err := cl.layer.Setup(main, header) + if err != nil { + logrus.Error("unable to setup layer layout", err) + return err + } + + if _, err = g.SetCurrentView(cl.layer.Name()); err != nil { + logrus.Error("unable to set view to layer", err) + return err + } + } + + //////////////////////////////////////////////////////////////////////////////////// + // Details + detailsMinY := minY + layersHeight + + // header + border + detailsHeaderHeight := 2 + + // note: maxY needs to account for the (invisible) border, thus a +1 + header, headerErr = g.SetView(cl.details.Name()+"header", minX, detailsMinY, maxX, detailsMinY+detailsHeaderHeight+1) + + // we are going to overlap the view over the (invisible) border (so minY will be one less than expected) + // additionally, maxY will be bumped by one to include the border + main, viewErr = g.SetView(cl.details.Name(), minX, detailsMinY+detailsHeaderHeight, maxX, maxY+1) + + if utils.IsNewView(viewErr, headerErr) { + err := cl.details.Setup(main, header) + if err != nil { + return err + } + } + + return nil +} + +func (cl *LayerDetailsCompoundLayout) RequestedSize(available int) *int { + return nil +} + +// todo: make this variable based on the nested views +func (cl *LayerDetailsCompoundLayout) IsVisible() bool { + return true +} diff --git a/runtime/ui/layout/layout.go b/runtime/ui/layout/layout.go new file mode 100644 index 00000000..abc0dbbc --- /dev/null +++ b/runtime/ui/layout/layout.go @@ -0,0 +1,11 @@ +package layout + +import "github.com/jroimartin/gocui" + +type Layout interface { + Name() string + Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error + RequestedSize(available int) *int + IsVisible() bool + OnLayoutChange() error +} diff --git a/runtime/ui/layout/location.go b/runtime/ui/layout/location.go new file mode 100644 index 00000000..ee1d6165 --- /dev/null +++ b/runtime/ui/layout/location.go @@ -0,0 +1,9 @@ +package layout + +const ( + LocationFooter Location = iota + LocationHeader + LocationColumn +) + +type Location int diff --git a/runtime/ui/layout/manager.go b/runtime/ui/layout/manager.go new file mode 100644 index 00000000..ddccfe96 --- /dev/null +++ b/runtime/ui/layout/manager.go @@ -0,0 +1,257 @@ +package layout + +import ( + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" +) + +type Manager struct { + lastX, lastY int + lastHeaderArea, lastFooterArea, lastColumnArea Area + elements map[Location][]Layout +} + +func NewManager() *Manager { + return &Manager{ + elements: make(map[Location][]Layout), + } +} + +func (lm *Manager) Add(element Layout, location Location) { + if _, exists := lm.elements[location]; !exists { + lm.elements[location] = make([]Layout, 0) + } + lm.elements[location] = append(lm.elements[location], element) +} + +func (lm *Manager) planAndLayoutHeaders(g *gocui.Gui, area Area) (Area, error) { + // layout headers top down + if elements, exists := lm.elements[LocationHeader]; exists { + for _, element := range elements { + // a visible header cannot take up the whole screen, default to 1. + // this eliminates the need to discover a default size based on all element requests + height := 0 + if element.IsVisible() { + requestedHeight := element.RequestedSize(area.maxY) + if requestedHeight != nil { + height = *requestedHeight + } else { + height = 1 + } + } + + // layout the header within the allocated space + err := element.Layout(g, area.minX, area.minY, area.maxX, area.minY+height) + if err != nil { + logrus.Errorf("failed to layout '%s' header: %+v", element.Name(), err) + return area, err + } + + // restrict the available screen real estate + area.minY += height + + } + } + return area, nil +} + +func (lm *Manager) planFooters(g *gocui.Gui, area Area) (Area, []int) { + var footerHeights = make([]int, 0) + // we need to layout the footers last, but account for them when drawing the columns. This block is for planning + // out the real estate needed for the footers now (but not laying out yet) + if elements, exists := lm.elements[LocationFooter]; exists { + footerHeights = make([]int, len(elements)) + for idx := range footerHeights { + footerHeights[idx] = 1 + } + + for idx, element := range elements { + // a visible footer cannot take up the whole screen, default to 1. + // this eliminates the need to discover a default size based on all element requests + height := 0 + if element.IsVisible() { + requestedHeight := element.RequestedSize(area.maxY) + if requestedHeight != nil { + height = *requestedHeight + } else { + height = 1 + } + } + footerHeights[idx] = height + } + // restrict the available screen real estate + for _, height := range footerHeights { + area.maxY -= height + } + } + return area, footerHeights +} + +func (lm *Manager) planAndLayoutColumns(g *gocui.Gui, area Area) (Area, error) { + // layout columns left to right + if elements, exists := lm.elements[LocationColumn]; exists { + widths := make([]int, len(elements)) + for idx := range widths { + widths[idx] = -1 + } + variableColumns := len(elements) + availableWidth := area.maxX + + // first pass: planout the column sizes based on the given requests + for idx, element := range elements { + if !element.IsVisible() { + widths[idx] = 0 + variableColumns-- + continue + } + + requestedWidth := element.RequestedSize(availableWidth) + if requestedWidth != nil { + widths[idx] = *requestedWidth + variableColumns-- + availableWidth -= widths[idx] + } + } + + defaultWidth := availableWidth / variableColumns + + // second pass: layout columns left to right (based off predetermined widths) + for idx, element := range elements { + // use the requested or default width + width := widths[idx] + if width == -1 { + width = defaultWidth + } + + // layout the column within the allocated space + err := element.Layout(g, area.minX, area.minY, area.minX+width, area.maxY) + if err != nil { + logrus.Errorf("failed to layout '%s' column: %+v", element.Name(), err) + return area, err + } + + // move left to right, scratching off real estate as it is taken + area.minX += width + + } + } + return area, nil +} + +func (lm *Manager) layoutFooters(g *gocui.Gui, area Area, footerHeights []int) error { + // layout footers top down (which is why the list is reversed). Top down is needed due to border overlap. + if elements, exists := lm.elements[LocationFooter]; exists { + for idx := len(elements) - 1; idx >= 0; idx-- { + element := elements[idx] + height := footerHeights[idx] + var topY, bottomY, bottomPadding int + for oIdx := 0; oIdx <= idx; oIdx++ { + bottomPadding += footerHeights[oIdx] + } + topY = area.maxY - bottomPadding - height + // +1 for border + bottomY = topY + height + 1 + + // layout the footer within the allocated space + // note: since the headers and rows are inclusive counting from -1 (to account for a border) we must + // do the same vertically, thus a -1 is needed for a starting Y + err := element.Layout(g, area.minX, topY, area.maxX, bottomY) + if err != nil { + logrus.Errorf("failed to layout '%s' footer: %+v", element.Name(), err) + return err + } + } + } + return nil +} + +func (lm *Manager) notifyLayoutChange() error { + for _, elements := range lm.elements { + for _, element := range elements { + err := element.OnLayoutChange() + if err != nil { + return err + } + } + } + return nil +} + +func (lm *Manager) Layout(g *gocui.Gui) error { + curMaxX, curMaxY := g.Size() + return lm.layout(g, curMaxX, curMaxY) +} + +// layout defines the definition of the window pane size and placement relations to one another. This +// is invoked at application start and whenever the screen dimensions change. +// A few things to note: +// 1. gocui has borders around all views (even if Frame=false). This means there are a lot of +1/-1 magic numbers +// needed (but there are comments!). +// 2. since there are borders, in order for it to appear as if there aren't any spaces for borders, the views must +// overlap. To prevent screen artifacts, all elements must be layedout from the top of the screen to the bottom. +func (lm *Manager) layout(g *gocui.Gui, curMaxX, curMaxY int) error { + var headerAreaChanged, footerAreaChanged, columnAreaChanged bool + + // compare current screen size with the last known size at time of layout + area := Area{ + minX: -1, + minY: -1, + maxX: curMaxX, + maxY: curMaxY, + } + + var hasResized bool + if curMaxX != lm.lastX || curMaxY != lm.lastY { + hasResized = true + } + lm.lastX, lm.lastY = curMaxX, curMaxY + + // pass 1: plan and layout elements + + // headers... + area, err := lm.planAndLayoutHeaders(g, area) + if err != nil { + return err + } + + if area != lm.lastHeaderArea { + headerAreaChanged = true + } + lm.lastHeaderArea = area + + // plan footers... don't layout until all columns have been layedout. This is necessary since we must layout from + // top to bottom, but we need the real estate planned for the footers to determine the bottom of the columns. + var footerArea = area + area, footerHeights := lm.planFooters(g, area) + + if area != lm.lastFooterArea { + footerAreaChanged = true + } + lm.lastFooterArea = area + + // columns... + area, err = lm.planAndLayoutColumns(g, area) + if err != nil { + return nil + } + + if area != lm.lastColumnArea { + columnAreaChanged = true + } + lm.lastColumnArea = area + + // footers... layout according to the original available area and planned heights + err = lm.layoutFooters(g, footerArea, footerHeights) + if err != nil { + return nil + } + + // pass 2: notify everyone of a layout change (allow to update and render) + // note: this may mean that each element will update and rerender, which may cause a secondary layout call. + // the conditions which we notify elements of layout changes must be very selective! + if hasResized || headerAreaChanged || footerAreaChanged || columnAreaChanged { + return lm.notifyLayoutChange() + } + + return nil +} diff --git a/runtime/ui/layout/manager_test.go b/runtime/ui/layout/manager_test.go new file mode 100644 index 00000000..da61dcbd --- /dev/null +++ b/runtime/ui/layout/manager_test.go @@ -0,0 +1,381 @@ +package layout + +import ( + "github.com/jroimartin/gocui" + "testing" +) + +type testElement struct { + t *testing.T + size int + layoutArea Area + location Location +} + +func newTestElement(t *testing.T, size int, layoutArea Area, location Location) *testElement { + return &testElement{ + t: t, + size: size, + layoutArea: layoutArea, + location: location, + } +} + +func (te *testElement) Name() string { + return "dont care" +} +func (te *testElement) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + actualLayoutArea := Area{ + minX: minX, + minY: minY, + maxX: maxX, + maxY: maxY, + } + + if te.layoutArea != actualLayoutArea { + te.t.Errorf("expected layout area '%+v', got '%+v'", te.layoutArea, actualLayoutArea) + } + return nil +} +func (te *testElement) RequestedSize(available int) *int { + if te.size == -1 { + return nil + } + return &te.size +} +func (te *testElement) IsVisible() bool { + return true +} +func (te *testElement) OnLayoutChange() error { + return nil +} + +type layoutReturn struct { + area Area + err error +} + +func Test_planAndLayoutHeaders(t *testing.T) { + + table := map[string]struct { + headers []*testElement + expected layoutReturn + }{ + "single header": { + headers: []*testElement{newTestElement(t, 1, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader)}, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 0, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two headers": { + headers: []*testElement{ + newTestElement(t, 1, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, Area{ + minX: -1, + minY: 0, + maxX: 120, + maxY: 1, + }, LocationHeader), + }, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two odd-sized headers": { + headers: []*testElement{ + newTestElement(t, 2, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 1, + }, LocationHeader), + newTestElement(t, 3, Area{ + minX: -1, + minY: 1, + maxX: 120, + maxY: 4, + }, LocationHeader), + }, + expected: layoutReturn{ + area: Area{ + minX: -1, + minY: 4, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.headers { + lm.Add(element, element.location) + } + + area, err := lm.planAndLayoutHeaders(nil, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 80, + }) + + if err != test.expected.err { + t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) + } + + if area != test.expected.area { + t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) + } + + } +} + +func Test_planAndLayoutColumns(t *testing.T) { + + table := map[string]struct { + columns []*testElement + expected layoutReturn + }{ + "single column": { + columns: []*testElement{newTestElement(t, -1, Area{ + minX: -1, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn)}, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two equal columns": { + columns: []*testElement{ + newTestElement(t, -1, Area{ + minX: -1, + minY: -1, + maxX: 59, + maxY: 80, + }, LocationColumn), + newTestElement(t, -1, Area{ + minX: 59, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn), + }, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + "two odd-sized columns": { + columns: []*testElement{ + newTestElement(t, 30, Area{ + minX: -1, + minY: -1, + maxX: 29, + maxY: 80, + }, LocationColumn), + newTestElement(t, -1, Area{ + minX: 29, + minY: -1, + maxX: 119, + maxY: 80, + }, LocationColumn), + }, + expected: layoutReturn{ + area: Area{ + minX: 119, + minY: -1, + maxX: 120, + maxY: 80, + }, + err: nil, + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.columns { + lm.Add(element, element.location) + } + + area, err := lm.planAndLayoutColumns(nil, Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 80, + }) + + if err != test.expected.err { + t.Errorf("%s: expected err '%+v', got error '%+v'", name, test.expected.err, err) + } + + if area != test.expected.area { + t.Errorf("%s: expected returned area '%+v', got area '%+v'", name, test.expected.area, area) + } + + } +} + +func Test_layout(t *testing.T) { + + table := map[string]struct { + elements []*testElement + }{ + "1 header + 1 footer + 1 column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + "1 header + 1 footer + 3 column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 39, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 39, + minY: 0, + maxX: 79, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 79, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + "1 header + 1 footer + 2 equal columns + 1 sized column": { + elements: []*testElement{ + newTestElement(t, 1, + Area{ + minX: -1, + minY: -1, + maxX: 120, + maxY: 0, + }, LocationHeader), + newTestElement(t, 1, + Area{ + minX: -1, + minY: 78, + maxX: 120, + maxY: 80, + }, LocationFooter), + newTestElement(t, -1, + Area{ + minX: -1, + minY: 0, + maxX: 19, + maxY: 79, + }, LocationColumn), + newTestElement(t, 80, + Area{ + minX: 19, + minY: 0, + maxX: 99, + maxY: 79, + }, LocationColumn), + newTestElement(t, -1, + Area{ + minX: 99, + minY: 0, + maxX: 119, + maxY: 79, + }, LocationColumn), + }, + }, + } + + for name, test := range table { + t.Log("case: ", name, " ---") + lm := NewManager() + for _, element := range test.elements { + lm.Add(element, element.location) + } + + err := lm.layout(nil, 120, 80) + + if err != nil { + t.Fatalf("%s: unexpected error: %+v", name, err) + } + } +} diff --git a/runtime/ui/layout_manager.go b/runtime/ui/layout_manager.go deleted file mode 100644 index 72f8c26d..00000000 --- a/runtime/ui/layout_manager.go +++ /dev/null @@ -1,169 +0,0 @@ -package ui - -import ( - "github.com/jroimartin/gocui" - "github.com/sirupsen/logrus" - "github.com/spf13/viper" -) - -type layoutManager struct { - fileTreeSplitRatio float64 - controllers *Controller -} - -// 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 { - logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", fileTreeSplitRatio) - fileTreeSplitRatio = 0.5 - } - - return &layoutManager{ - fileTreeSplitRatio: fileTreeSplitRatio, - controllers: c, - } -} - -// IsNewView determines if a view has already been created based on the set of errors given (a bit hokie) -func IsNewView(errs ...error) bool { - for _, err := range errs { - if err == nil { - return false - } - if err != gocui.ErrUnknownView { - return false - } - } - return true -} - -// layout defines the definition of the window pane size and placement relations to one another. This -// is invoked at application start and whenever the screen dimensions change. -func (lm *layoutManager) layout(g *gocui.Gui) error { - // TODO: this logic should be refactored into an abstraction that takes care of the math for us - - maxX, maxY := g.Size() - var resized bool - if maxX != lastX { - resized = true - } - if maxY != lastY { - resized = true - } - lastX, lastY = maxX, maxY - - splitCols := int(float64(maxX) * (1.0 - lm.fileTreeSplitRatio)) - debugWidth := 0 - if debug { - debugWidth = maxX / 4 - } - debugCols := maxX - debugWidth - bottomRows := 1 - headerRows := 2 - - filterBarHeight := 1 - statusBarHeight := 1 - - statusBarIndex := 1 - filterBarIndex := 2 - - layersHeight := len(lm.controllers.Layer.Layers) + headerRows + 1 // layers + header + base image layer row - maxLayerHeight := int(0.75 * float64(maxY)) - if layersHeight > maxLayerHeight { - layersHeight = maxLayerHeight - } - - var view, header *gocui.View - var viewErr, headerErr, err error - - if !lm.controllers.Filter.IsVisible() { - bottomRows-- - filterBarHeight = 0 - } - - // Debug pane - if debug { - if _, err := g.SetView("debug", debugCols, -1, maxX, maxY-bottomRows); err != nil { - if err != gocui.ErrUnknownView { - return err - } - } - } - - // Layers - view, viewErr = g.SetView(lm.controllers.Layer.Name(), -1, -1+headerRows, splitCols, layersHeight) - header, headerErr = g.SetView(lm.controllers.Layer.Name()+"header", -1, -1, splitCols, headerRows) - if IsNewView(viewErr, headerErr) { - err = lm.controllers.Layer.Setup(view, header) - if err != nil { - logrus.Error("unable to setup layer controller", err) - return err - } - - if _, err = g.SetCurrentView(lm.controllers.Layer.Name()); err != nil { - logrus.Error("unable to set view to layer", err) - return err - } - // since we are selecting the view, we should rerender to indicate it is selected - err = lm.controllers.Layer.Render() - if err != nil { - logrus.Error("unable to render layer view", err) - return err - } - } - - // Details - view, viewErr = g.SetView(lm.controllers.Details.Name(), -1, -1+layersHeight+headerRows, splitCols, maxY-bottomRows) - header, headerErr = g.SetView(lm.controllers.Details.Name()+"header", -1, -1+layersHeight, splitCols, layersHeight+headerRows) - if IsNewView(viewErr, headerErr) { - err = lm.controllers.Details.Setup(view, header) - if err != nil { - return err - } - } - - // Filetree - offset := 0 - if !lm.controllers.Tree.AreAttributesVisible() { - offset = 1 - } - view, viewErr = g.SetView(lm.controllers.Tree.Name(), splitCols, -1+headerRows-offset, debugCols, maxY-bottomRows) - header, headerErr = g.SetView(lm.controllers.Tree.Name()+"header", splitCols, -1, debugCols, headerRows-offset) - if IsNewView(viewErr, headerErr) { - err = lm.controllers.Tree.Setup(view, header) - if err != nil { - logrus.Error("unable to setup tree controller", err) - return err - } - } - err = lm.controllers.Tree.OnLayoutChange(resized) - if err != nil { - logrus.Error("unable to setup layer controller onLayoutChange", err) - return err - } - - // Status Bar - view, viewErr = g.SetView(lm.controllers.Status.Name(), -1, maxY-statusBarHeight-statusBarIndex, maxX, maxY-(statusBarIndex-1)) - if IsNewView(viewErr, headerErr) { - err = lm.controllers.Status.Setup(view, nil) - if err != nil { - logrus.Error("unable to setup status controller", err) - return err - } - } - - // Filter Bar - view, viewErr = g.SetView(lm.controllers.Filter.Name(), len(lm.controllers.Filter.HeaderStr())-1, maxY-filterBarHeight-filterBarIndex, maxX, maxY-(filterBarIndex-1)) - header, headerErr = g.SetView(lm.controllers.Filter.Name()+"header", -1, maxY-filterBarHeight-filterBarIndex, len(lm.controllers.Filter.HeaderStr()), maxY-(filterBarIndex-1)) - if IsNewView(viewErr, headerErr) { - err = lm.controllers.Filter.Setup(view, header) - if err != nil { - logrus.Error("unable to setup filter controller", err) - return err - } - } - - return nil -} diff --git a/runtime/ui/view/debug.go b/runtime/ui/view/debug.go new file mode 100644 index 00000000..aab9d064 --- /dev/null +++ b/runtime/ui/view/debug.go @@ -0,0 +1,122 @@ +package view + +import ( + "fmt" + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/wagoodman/dive/runtime/ui/format" + "github.com/wagoodman/dive/utils" +) + +// Debug is just for me :) +type Debug struct { + name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + + selectedView Helper +} + +// newDebugView creates a new view object attached the the global [gocui] screen object. +func newDebugView(gui *gocui.Gui) (controller *Debug) { + controller = new(Debug) + + // populate main fields + controller.name = "debug" + controller.gui = gui + + return controller +} + +func (v *Debug) SetCurrentView(r Helper) { + v.selectedView = r +} + +func (v *Debug) Name() string { + return v.name +} + +// Setup initializes the UI concerns within the context of a global [gocui] view object. +func (v *Debug) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) + + // set controller options + v.view = view + v.view.Editable = false + v.view.Wrap = false + v.view.Frame = false + + v.header = header + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false + + return v.Render() +} + +// IsVisible indicates if the status view pane is currently initialized. +func (v *Debug) IsVisible() bool { + return v != nil +} + +// Update refreshes the state objects for future rendering (currently does nothing). +func (v *Debug) Update() error { + return nil +} + +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Debug) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() +} + +// Render flushes the state objects to the screen. +func (v *Debug) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) + + v.gui.Update(func(g *gocui.Gui) error { + // update header... + v.header.Clear() + width, _ := g.Size() + headerStr := format.RenderHeader("Debug", width, false) + _, _ = fmt.Fprintln(v.header, headerStr) + + // update view... + v.view.Clear() + _, err := fmt.Fprintln(v.view, "blerg") + if err != nil { + logrus.Debug("unable to write to buffer: ", err) + } + + return nil + }) + return nil +} + +func (v *Debug) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name()) + + // header + headerSize := 1 + // note: maxY needs to account for the (invisible) border, thus a +1 + header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1) + // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). + // additionally, maxY will be bumped by one to include the border + view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1) + if utils.IsNewView(viewErr, headerErr) { + err := v.Setup(view, header) + if err != nil { + logrus.Error("unable to setup debug controller", err) + return err + } + } + return nil +} + +func (v *Debug) RequestedSize(available int) *int { + return nil +} diff --git a/runtime/ui/view/details.go b/runtime/ui/view/details.go index 9cb5ce0b..95ee962c 100644 --- a/runtime/ui/view/details.go +++ b/runtime/ui/view/details.go @@ -12,7 +12,6 @@ import ( "github.com/dustin/go-humanize" "github.com/jroimartin/gocui" - "github.com/lunixbochs/vtclean" ) // Details holds the UI objects and data models for populating the lower-left pane. Specifically the pane that @@ -29,12 +28,12 @@ type Details struct { currentLayer *image.Layer } -// 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) { +// newDetailsView creates a new view object attached the the global [gocui] screen object. +func newDetailsView(gui *gocui.Gui, efficiency float64, inefficiencies filetree.EfficiencySlice, imageSize uint64) (controller *Details) { controller = new(Details) // populate main fields - controller.name = name + controller.name = "details" controller.gui = gui controller.efficiency = efficiency controller.inefficiencies = inefficiencies @@ -43,68 +42,78 @@ func NewDetailsView(name string, gui *gocui.Gui, efficiency float64, inefficienc return controller } -func (c *Details) Name() string { - return c.name +func (v *Details) Name() string { + return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (c *Details) Setup(v *gocui.View, header *gocui.View) error { +func (v *Details) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) // set controller options - c.view = v - c.view.Editable = false - c.view.Wrap = true - c.view.Highlight = false - c.view.Frame = false + v.view = view + v.view.Editable = false + v.view.Wrap = true + v.view.Highlight = false + v.view.Frame = false - c.header = header - c.header.Editable = false - c.header.Wrap = false - c.header.Frame = false + v.header = header + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false var infos = []key.BindingInfo{ { Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, - OnAction: c.CursorDown, + OnAction: v.CursorDown, }, { Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, - OnAction: c.CursorUp, + OnAction: v.CursorUp, }, } - _, err := key.GenerateBindings(c.gui, c.name, infos) + _, err := key.GenerateBindings(v.gui, v.name, infos) if err != nil { return err } - return c.Render() + return v.Render() } // IsVisible indicates if the details view pane is currently initialized. -func (c *Details) IsVisible() bool { - return c != nil +func (v *Details) IsVisible() bool { + return v != nil } // CursorDown moves the cursor down in the details pane (currently indicates nothing). -func (c *Details) CursorDown() error { - return CursorDown(c.gui, c.view) +func (v *Details) CursorDown() error { + return CursorDown(v.gui, v.view) } // CursorUp moves the cursor up in the details pane (currently indicates nothing). -func (c *Details) CursorUp() error { - return CursorUp(c.gui, c.view) +func (v *Details) CursorUp() error { + return CursorUp(v.gui, v.view) +} + +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Details) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() } // Update refreshes the state objects for future rendering. -func (c *Details) Update() error { +func (v *Details) Update() error { return nil } -func (c *Details) SetCurrentLayer(layer *image.Layer) { - c.currentLayer = layer +func (v *Details) SetCurrentLayer(layer *image.Layer) { + v.currentLayer = layer } // Render flushes the state objects to the screen. The details pane reports: @@ -112,8 +121,10 @@ func (c *Details) SetCurrentLayer(layer *image.Layer) { // 2. the image efficiency score // 3. the estimated wasted image space // 4. a list of inefficient file allocations -func (c *Details) Render() error { - if c.currentLayer == nil { +func (v *Details) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) + + if v.currentLayer == nil { return fmt.Errorf("no layer selected") } @@ -123,12 +134,12 @@ func (c *Details) Render() error { inefficiencyReport := fmt.Sprintf(format.Header(template), "Count", "Total Space", "Path") height := 100 - if c.view != nil { - _, height = c.view.Size() + if v.view != nil { + _, height = v.view.Size() } - for idx := 0; idx < len(c.inefficiencies); idx++ { - data := c.inefficiencies[len(c.inefficiencies)-1-idx] + for idx := 0; idx < len(v.inefficiencies); idx++ { + data := v.inefficiencies[len(v.inefficiencies)-1-idx] wastedSpace += data.CumulativeSize // todo: make this report scrollable @@ -137,43 +148,43 @@ func (c *Details) Render() error { } } - 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)) + imageSizeStr := fmt.Sprintf("%s %s", format.Header("Total Image size:"), humanize.Bytes(v.imageSize)) + effStr := fmt.Sprintf("%s %d %%", format.Header("Image efficiency score:"), int(100.0*v.efficiency)) wastedSpaceStr := fmt.Sprintf("%s %s", format.Header("Potential wasted space:"), humanize.Bytes(uint64(wastedSpace))) - c.gui.Update(func(g *gocui.Gui) error { + v.gui.Update(func(g *gocui.Gui) error { // update header - c.header.Clear() - width, _ := c.view.Size() + v.header.Clear() + width, _ := v.view.Size() - layerHeaderStr := fmt.Sprintf("[Layer Details]%s", strings.Repeat("─", width-15)) - imageHeaderStr := fmt.Sprintf("[Image Details]%s", strings.Repeat("─", width-15)) + layerHeaderStr := format.RenderHeader("Layer Details", width, false) + imageHeaderStr := format.RenderHeader("Image Details", width, false) - _, err := fmt.Fprintln(c.header, format.Header(vtclean.Clean(layerHeaderStr, false))) + _, err := fmt.Fprintln(v.header, layerHeaderStr) if err != nil { return err } // update contents - c.view.Clear() + v.view.Clear() var lines = make([]string, 0) - if c.currentLayer.Names != nil && len(c.currentLayer.Names) > 0 { - lines = append(lines, format.Header("Tags: ")+strings.Join(c.currentLayer.Names, ", ")) + if v.currentLayer.Names != nil && len(v.currentLayer.Names) > 0 { + lines = append(lines, format.Header("Tags: ")+strings.Join(v.currentLayer.Names, ", ")) } else { lines = append(lines, format.Header("Tags: ")+"(none)") } - lines = append(lines, format.Header("Id: ")+c.currentLayer.Id) - lines = append(lines, format.Header("Digest: ")+c.currentLayer.Digest) + lines = append(lines, format.Header("Id: ")+v.currentLayer.Id) + lines = append(lines, format.Header("Digest: ")+v.currentLayer.Digest) lines = append(lines, format.Header("Command:")) - lines = append(lines, c.currentLayer.Command) - lines = append(lines, "\n"+format.Header(vtclean.Clean(imageHeaderStr, false))) + lines = append(lines, v.currentLayer.Command) + lines = append(lines, "\n"+imageHeaderStr) lines = append(lines, imageSizeStr) lines = append(lines, wastedSpaceStr) lines = append(lines, effStr+"\n") lines = append(lines, inefficiencyReport) - _, err = fmt.Fprintln(c.view, strings.Join(lines, "\n")) + _, err = fmt.Fprintln(v.view, strings.Join(lines, "\n")) if err != nil { logrus.Debug("unable to write to buffer: ", err) } @@ -183,6 +194,6 @@ func (c *Details) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected (currently does nothing). -func (c *Details) KeyHelp() string { +func (v *Details) KeyHelp() string { return "TBD" } diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go index d460892e..d306b979 100644 --- a/runtime/ui/view/filetree.go +++ b/runtime/ui/view/filetree.go @@ -2,16 +2,15 @@ package view import ( "fmt" + "github.com/jroimartin/gocui" "github.com/sirupsen/logrus" + "github.com/spf13/viper" + "github.com/wagoodman/dive/dive/filetree" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" "github.com/wagoodman/dive/runtime/ui/viewmodel" + "github.com/wagoodman/dive/utils" "regexp" - "strings" - - "github.com/jroimartin/gocui" - "github.com/lunixbochs/vtclean" - "github.com/wagoodman/dive/dive/filetree" ) const ( @@ -33,177 +32,184 @@ type FileTree struct { vm *viewmodel.FileTree title string - filterRegex *regexp.Regexp - - listeners []ViewOptionChangeListener - - helpKeys []*key.Binding + filterRegex *regexp.Regexp + listeners []ViewOptionChangeListener + helpKeys []*key.Binding + requestedWidthRatio float64 } -// 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.Comparer) (controller *FileTree, err error) { +// newFileTreeView creates a new view object attached the the global [gocui] screen object. +func newFileTreeView(gui *gocui.Gui, tree *filetree.FileTree, refTrees []*filetree.FileTree, cache filetree.Comparer) (controller *FileTree, err error) { controller = new(FileTree) controller.listeners = make([]ViewOptionChangeListener, 0) // populate main fields - controller.name = name + controller.name = "filetree" controller.gui = gui controller.vm, err = viewmodel.NewFileTreeViewModel(tree, refTrees, cache) if err != nil { return nil, err } + requestedWidthRatio := viper.GetFloat64("filetree.pane-width") + if requestedWidthRatio >= 1 || requestedWidthRatio <= 0 { + logrus.Errorf("invalid config value: 'filetree.pane-width' should be 0 < value < 1, given '%v'", requestedWidthRatio) + requestedWidthRatio = 0.5 + } + controller.requestedWidthRatio = requestedWidthRatio + return controller, err } -func (c *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) { - c.listeners = append(c.listeners, listener...) +func (v *FileTree) AddViewOptionChangeListener(listener ...ViewOptionChangeListener) { + v.listeners = append(v.listeners, listener...) } -func (c *FileTree) SetTitle(title string) { - c.title = title +func (v *FileTree) SetTitle(title string) { + v.title = title } -func (c *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) { - c.filterRegex = filterRegex +func (v *FileTree) SetFilterRegex(filterRegex *regexp.Regexp) { + v.filterRegex = filterRegex } -func (c *FileTree) Name() string { - return c.name +func (v *FileTree) Name() string { + return v.name } -func (c *FileTree) AreAttributesVisible() bool { - return c.vm.ShowAttributes +func (v *FileTree) areAttributesVisible() bool { + return v.vm.ShowAttributes } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (c *FileTree) Setup(v *gocui.View, header *gocui.View) error { +func (v *FileTree) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) // set controller options - c.view = v - c.view.Editable = false - c.view.Wrap = false - c.view.Frame = false + v.view = view + v.view.Editable = false + v.view.Wrap = false + v.view.Frame = false - c.header = header - c.header.Editable = false - c.header.Wrap = false - c.header.Frame = false + v.header = header + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false var infos = []key.BindingInfo{ { ConfigKeys: []string{"keybinding.toggle-collapse-dir"}, - OnAction: c.toggleCollapse, + OnAction: v.toggleCollapse, Display: "Collapse dir", }, { ConfigKeys: []string{"keybinding.toggle-collapse-all-dir"}, - OnAction: c.toggleCollapseAll, + OnAction: v.toggleCollapseAll, Display: "Collapse all dir", }, { ConfigKeys: []string{"keybinding.toggle-added-files"}, - OnAction: func() error { return c.toggleShowDiffType(filetree.Added) }, - IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Added] }, + OnAction: func() error { return v.toggleShowDiffType(filetree.Added) }, + IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Added] }, Display: "Added", }, { ConfigKeys: []string{"keybinding.toggle-removed-files"}, - OnAction: func() error { return c.toggleShowDiffType(filetree.Removed) }, - IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Removed] }, + OnAction: func() error { return v.toggleShowDiffType(filetree.Removed) }, + IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Removed] }, Display: "Removed", }, { ConfigKeys: []string{"keybinding.toggle-modified-files"}, - OnAction: func() error { return c.toggleShowDiffType(filetree.Modified) }, - IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Modified] }, + OnAction: func() error { return v.toggleShowDiffType(filetree.Modified) }, + IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Modified] }, Display: "Modified", }, { ConfigKeys: []string{"keybinding.toggle-unchanged-files", "keybinding.toggle-unmodified-files"}, - OnAction: func() error { return c.toggleShowDiffType(filetree.Unmodified) }, - IsSelected: func() bool { return !c.vm.HiddenDiffTypes[filetree.Unmodified] }, + OnAction: func() error { return v.toggleShowDiffType(filetree.Unmodified) }, + IsSelected: func() bool { return !v.vm.HiddenDiffTypes[filetree.Unmodified] }, Display: "Unmodified", }, { ConfigKeys: []string{"keybinding.toggle-filetree-attributes"}, - OnAction: c.toggleAttributes, - IsSelected: func() bool { return c.vm.ShowAttributes }, + OnAction: v.toggleAttributes, + IsSelected: func() bool { return v.vm.ShowAttributes }, Display: "Attributes", }, { ConfigKeys: []string{"keybinding.page-up"}, - OnAction: c.PageUp, + OnAction: v.PageUp, }, { ConfigKeys: []string{"keybinding.page-down"}, - OnAction: c.PageDown, + OnAction: v.PageDown, }, { Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, - OnAction: c.CursorDown, + OnAction: v.CursorDown, }, { Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, - OnAction: c.CursorUp, + OnAction: v.CursorUp, }, { Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, - OnAction: c.CursorLeft, + OnAction: v.CursorLeft, }, { Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, - OnAction: c.CursorRight, + OnAction: v.CursorRight, }, } - helpKeys, err := key.GenerateBindings(c.gui, c.name, infos) + helpKeys, err := key.GenerateBindings(v.gui, v.name, infos) if err != nil { return err } - c.helpKeys = helpKeys + v.helpKeys = helpKeys - _, height := c.view.Size() - c.vm.Setup(0, height) - _ = c.Update() - _ = c.Render() + _, height := v.view.Size() + v.vm.Setup(0, height) + _ = v.Update() + _ = v.Render() return nil } // IsVisible indicates if the file tree view pane is currently initialized -func (c *FileTree) IsVisible() bool { - return c != nil +func (v *FileTree) IsVisible() bool { + return v != nil } // ResetCursor moves the cursor back to the top of the buffer and translates to the top of the buffer. -func (c *FileTree) resetCursor() { - _ = c.view.SetCursor(0, 0) - c.vm.ResetCursor() +func (v *FileTree) resetCursor() { + _ = v.view.SetCursor(0, 0) + v.vm.ResetCursor() } // SetTreeByLayer populates the view model by stacking the indicated image layer file trees. -func (c *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { - err := c.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) +func (v *FileTree) SetTree(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) error { + err := v.vm.SetTreeByLayer(bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop) if err != nil { return err } - _ = c.Update() - return c.Render() + _ = v.Update() + return v.Render() } // CursorDown moves the cursor down and renders the view. // Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing // this range into the view buffer. This is much faster when tree sizes are large. -func (c *FileTree) CursorDown() error { - if c.vm.CursorDown() { - return c.Render() +func (v *FileTree) CursorDown() error { + if v.vm.CursorDown() { + return v.Render() } return nil } @@ -212,49 +218,49 @@ func (c *FileTree) CursorDown() error { // Note: we cannot use the gocui buffer since any state change requires writing the entire tree to the buffer. // Instead we are keeping an upper and lower bounds of the tree string to render and only flushing // this range into the view buffer. This is much faster when tree sizes are large. -func (c *FileTree) CursorUp() error { - if c.vm.CursorUp() { - return c.Render() +func (v *FileTree) CursorUp() error { + if v.vm.CursorUp() { + return v.Render() } return nil } // 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(c.filterRegex) +func (v *FileTree) CursorLeft() error { + err := v.vm.CursorLeft(v.filterRegex) if err != nil { return err } - _ = c.Update() - return c.Render() + _ = v.Update() + return v.Render() } // CursorRight descends into directory expanding it if needed -func (c *FileTree) CursorRight() error { - err := c.vm.CursorRight(c.filterRegex) +func (v *FileTree) CursorRight() error { + err := v.vm.CursorRight(v.filterRegex) if err != nil { return err } - _ = c.Update() - return c.Render() + _ = v.Update() + return v.Render() } // PageDown moves to next page putting the cursor on top -func (c *FileTree) PageDown() error { - err := c.vm.PageDown() +func (v *FileTree) PageDown() error { + err := v.vm.PageDown() if err != nil { return err } - return c.Render() + return v.Render() } // PageUp moves to previous page putting the cursor on top -func (c *FileTree) PageUp() error { - err := c.vm.PageUp() +func (v *FileTree) PageUp() error { + err := v.vm.PageUp() if err != nil { return err } - return c.Render() + return v.Render() } // getAbsPositionNode determines the selected screen cursor's location in the file tree, returning the selected FileNode. @@ -263,30 +269,30 @@ func (c *FileTree) PageUp() error { // } // ToggleCollapse will collapse/expand the selected FileNode. -func (c *FileTree) toggleCollapse() error { - err := c.vm.ToggleCollapse(c.filterRegex) +func (v *FileTree) toggleCollapse() error { + err := v.vm.ToggleCollapse(v.filterRegex) if err != nil { return err } - _ = c.Update() - return c.Render() + _ = v.Update() + return v.Render() } // ToggleCollapseAll will collapse/expand the all directories. -func (c *FileTree) toggleCollapseAll() error { - err := c.vm.ToggleCollapseAll() +func (v *FileTree) toggleCollapseAll() error { + err := v.vm.ToggleCollapseAll() if err != nil { return err } - if c.vm.CollapseAll { - c.resetCursor() + if v.vm.CollapseAll { + v.resetCursor() } - _ = c.Update() - return c.Render() + _ = v.Update() + return v.Render() } -func (c *FileTree) notifyOnViewOptionChangeListeners() error { - for _, listener := range c.listeners { +func (v *FileTree) notifyOnViewOptionChangeListeners() error { + for _, listener := range v.listeners { err := listener() if err != nil { logrus.Errorf("notifyOnViewOptionChangeListeners error: %+v", err) @@ -297,95 +303,89 @@ func (c *FileTree) notifyOnViewOptionChangeListeners() error { } // ToggleAttributes will show/hide file attributes -func (c *FileTree) toggleAttributes() error { - err := c.vm.ToggleAttributes() +func (v *FileTree) toggleAttributes() error { + err := v.vm.ToggleAttributes() if err != nil { return err } - err = c.Update() + err = v.Update() if err != nil { return err } - err = c.Render() + err = v.Render() if err != nil { return err } // we need to render the changes to the status pane as well (not just this contoller/view) - return c.notifyOnViewOptionChangeListeners() + return v.notifyOnViewOptionChangeListeners() } // ToggleShowDiffType will show/hide the selected DiffType in the filetree pane. -func (c *FileTree) toggleShowDiffType(diffType filetree.DiffType) error { - c.vm.ToggleShowDiffType(diffType) +func (v *FileTree) toggleShowDiffType(diffType filetree.DiffType) error { + v.vm.ToggleShowDiffType(diffType) - err := c.Update() + err := v.Update() if err != nil { return err } - err = c.Render() + err = v.Render() if err != nil { return err } // we need to render the changes to the status pane as well (not just this contoller/view) - return c.notifyOnViewOptionChangeListeners() + return v.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 { - err := c.Update() +func (v *FileTree) OnLayoutChange() error { + err := v.Update() if err != nil { return err } - - if resized { - return c.Render() - } - return nil + return v.Render() } // Update refreshes the state objects for future rendering. -func (c *FileTree) Update() error { +func (v *FileTree) Update() error { var width, height int - if c.view != nil { - width, height = c.view.Size() + if v.view != nil { + width, height = v.view.Size() } else { // before the TUI is setup there may not be a controller to reference. Use the entire screen as reference. - width, height = c.gui.Size() + width, height = v.gui.Size() } // height should account for the header - return c.vm.Update(c.filterRegex, width, height-1) + return v.vm.Update(v.filterRegex, width, height-1) } // Render flushes the state objects (file tree) to the pane. -func (c *FileTree) Render() error { - title := c.title - // indicate when selected - if c.gui.CurrentView() == c.view { - title = "● " + c.title - } +func (v *FileTree) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) - c.gui.Update(func(g *gocui.Gui) error { + title := v.title + isSelected := v.gui.CurrentView() == v.view + + v.gui.Update(func(g *gocui.Gui) error { // update the header - c.header.Clear() + v.header.Clear() width, _ := g.Size() - headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) - if c.vm.ShowAttributes { + headerStr := format.RenderHeader(title, width, isSelected) + if v.vm.ShowAttributes { headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") } - - _, _ = fmt.Fprintln(c.header, format.Header(vtclean.Clean(headerStr, false))) + _, _ = fmt.Fprintln(v.header, headerStr, false) // update the contents - c.view.Clear() - err := c.vm.Render() + v.view.Clear() + err := v.vm.Render() if err != nil { return err } - _, err = fmt.Fprint(c.view, c.vm.Buffer.String()) + _, err = fmt.Fprint(v.view, v.vm.Buffer.String()) return err }) @@ -393,10 +393,38 @@ func (c *FileTree) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (c *FileTree) KeyHelp() string { +func (v *FileTree) KeyHelp() string { var help string - for _, binding := range c.helpKeys { + for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help } + +func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name()) + attributeRowSize := 0 + if !v.areAttributesVisible() { + attributeRowSize = 1 + } + // header + attribute + border + headerSize := 1 + attributeRowSize + 1 + // note: maxY needs to account for the (invisible) border, thus a +1 + header, headerErr := g.SetView(v.Name()+"header", minX, minY, maxX, minY+headerSize+1) + // we are going to overlap the view over the (invisible) border (so minY will be one less than expected). + // additionally, maxY will be bumped by one to include the border + view, viewErr := g.SetView(v.Name(), minX, minY+headerSize, maxX, maxY+1) + if utils.IsNewView(viewErr, headerErr) { + err := v.Setup(view, header) + if err != nil { + logrus.Error("unable to setup tree controller", err) + return err + } + } + return nil +} + +func (v *FileTree) RequestedSize(available int) *int { + var requestedWidth = int(float64(available) * (1.0 - v.requestedWidthRatio)) + return &requestedWidth +} diff --git a/runtime/ui/view/filter.go b/runtime/ui/view/filter.go index e32957a1..e8f911b3 100644 --- a/runtime/ui/view/filter.go +++ b/runtime/ui/view/filter.go @@ -5,6 +5,7 @@ import ( "github.com/jroimartin/gocui" "github.com/sirupsen/logrus" "github.com/wagoodman/dive/runtime/ui/format" + "github.com/wagoodman/dive/utils" "strings" ) @@ -13,70 +14,74 @@ 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 { - name string - gui *gocui.Gui - view *gocui.View - header *gocui.View - headerStr string - maxLength int - hidden bool + name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + labelStr string + maxLength int + hidden bool + requestedHeight int filterEditListeners []FilterEditListener } -// NewFilterView creates a new view object attached the the global [gocui] screen object. -func NewFilterView(name string, gui *gocui.Gui) (controller *Filter) { +// newFilterView creates a new view object attached the the global [gocui] screen object. +func newFilterView(gui *gocui.Gui) (controller *Filter) { controller = new(Filter) controller.filterEditListeners = make([]FilterEditListener, 0) // populate main fields - controller.name = name + controller.name = "filter" controller.gui = gui - controller.headerStr = "Path Filter: " + controller.labelStr = "Path Filter: " controller.hidden = true + controller.requestedHeight = 1 + return controller } -func (c *Filter) AddFilterEditListener(listener ...FilterEditListener) { - c.filterEditListeners = append(c.filterEditListeners, listener...) +func (v *Filter) AddFilterEditListener(listener ...FilterEditListener) { + v.filterEditListeners = append(v.filterEditListeners, listener...) } -func (c *Filter) Name() string { - return c.name +func (v *Filter) Name() string { + return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (c *Filter) Setup(v *gocui.View, header *gocui.View) error { +func (v *Filter) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) // set controller options - c.view = v - c.maxLength = 200 - c.view.Frame = false - c.view.BgColor = gocui.AttrReverse - c.view.Editable = true - c.view.Editor = c - - c.header = header - c.header.BgColor = gocui.AttrReverse - c.header.Editable = false - c.header.Wrap = false - c.header.Frame = false - - return c.Render() + v.view = view + v.maxLength = 200 + v.view.Frame = false + v.view.BgColor = gocui.AttrReverse + v.view.Editable = true + v.view.Editor = v + + v.header = header + v.header.BgColor = gocui.AttrReverse + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false + + return v.Render() } // ToggleFilterView shows/hides the file tree filter pane. -func (c *Filter) ToggleVisible() error { +func (v *Filter) ToggleVisible() error { // delete all user input from the tree view - c.view.Clear() + v.view.Clear() // toggle hiding - c.hidden = !c.hidden + v.hidden = !v.hidden - if !c.hidden { - _, err := c.gui.SetCurrentView(c.name) + if !v.hidden { + _, err := v.gui.SetCurrentView(v.name) if err != nil { logrus.Error("unable to toggle filter view: ", err) return err @@ -87,57 +92,42 @@ func (c *Filter) ToggleVisible() error { // reset the cursor for the next time it is visible // Note: there is a subtle gocui behavior here where this cannot be called when the view // is newly visible. Is this a problem with dive or gocui? - return c.view.SetCursor(0, 0) -} - -// todo: remove the need for this -func (c *Filter) HeaderStr() string { - return c.headerStr + return v.view.SetCursor(0, 0) } // IsVisible indicates if the filter view pane is currently initialized -func (c *Filter) IsVisible() bool { - if c == nil { +func (v *Filter) IsVisible() bool { + if v == nil { return false } - return !c.hidden -} - -// CursorDown moves the cursor down in the filter pane (currently indicates nothing). -func (c *Filter) CursorDown() error { - return nil -} - -// CursorUp moves the cursor up in the filter pane (currently indicates nothing). -func (c *Filter) CursorUp() error { - return nil + return !v.hidden } // Edit intercepts the key press events in the filer view to update the file view in real time. -func (c *Filter) Edit(v *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { - if !c.IsVisible() { +func (v *Filter) Edit(view *gocui.View, key gocui.Key, ch rune, mod gocui.Modifier) { + if !v.IsVisible() { return } - cx, _ := v.Cursor() - ox, _ := v.Origin() - limit := ox+cx+1 > c.maxLength + cx, _ := view.Cursor() + ox, _ := view.Origin() + limit := ox+cx+1 > v.maxLength switch { case ch != 0 && mod == 0 && !limit: - v.EditWrite(ch) + view.EditWrite(ch) case key == gocui.KeySpace && !limit: - v.EditWrite(' ') + view.EditWrite(' ') case key == gocui.KeyBackspace || key == gocui.KeyBackspace2: - v.EditDelete(true) + view.EditDelete(true) } // notify listeners - c.notifyFilterEditListeners() + v.notifyFilterEditListeners() } -func (c *Filter) notifyFilterEditListeners() { - currentValue := strings.TrimSpace(c.view.Buffer()) - for _, listener := range c.filterEditListeners { +func (v *Filter) notifyFilterEditListeners() { + currentValue := strings.TrimSpace(v.view.Buffer()) + for _, listener := range v.filterEditListeners { err := listener(currentValue) if err != nil { // note: cannot propagate error from here since this is from the main gogui thread @@ -147,14 +137,16 @@ func (c *Filter) notifyFilterEditListeners() { } // Update refreshes the state objects for future rendering (currently does nothing). -func (c *Filter) Update() error { +func (v *Filter) Update() error { return nil } // Render flushes the state objects to the screen. Currently this is the users path filter input. -func (c *Filter) Render() error { - c.gui.Update(func(g *gocui.Gui) error { - _, err := fmt.Fprintln(c.header, format.Header(c.headerStr)) +func (v *Filter) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) + + v.gui.Update(func(g *gocui.Gui) error { + _, err := fmt.Fprintln(v.header, format.Header(v.labelStr)) if err != nil { logrus.Error("unable to write to buffer: ", err) } @@ -164,6 +156,35 @@ func (c *Filter) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (c *Filter) KeyHelp() string { +func (v *Filter) KeyHelp() string { return format.StatusControlNormal("▏Type to filter the file tree ") } + +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Filter) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() +} + +func (v *Filter) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name()) + + label, labelErr := g.SetView(v.Name()+"label", minX, minY, len(v.labelStr), maxY) + view, viewErr := g.SetView(v.Name(), minX+(len(v.labelStr)-1), minY, maxX, maxY) + + if utils.IsNewView(viewErr, labelErr) { + err := v.Setup(view, label) + if err != nil { + logrus.Error("unable to setup status controller", err) + return err + } + } + return nil +} + +func (v *Filter) RequestedSize(available int) *int { + return &v.requestedHeight +} diff --git a/runtime/ui/view/layer.go b/runtime/ui/view/layer.go index 5d3c678c..578689df 100644 --- a/runtime/ui/view/layer.go +++ b/runtime/ui/view/layer.go @@ -2,16 +2,13 @@ package view import ( "fmt" + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" + "github.com/spf13/viper" "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" - "github.com/lunixbochs/vtclean" - "github.com/sirupsen/logrus" - "github.com/spf13/viper" ) type LayerChangeListener func(viewmodel.LayerSelection) error @@ -33,14 +30,14 @@ type Layer struct { helpKeys []*key.Binding } -// 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) { +// newLayerView creates a new view object attached the the global [gocui] screen object. +func newLayerView(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.name = "layer" controller.gui = gui controller.Layers = layers @@ -56,20 +53,20 @@ func NewLayerView(name string, gui *gocui.Gui, layers []*image.Layer) (controlle return controller, err } -func (c *Layer) AddLayerChangeListener(listener ...LayerChangeListener) { - c.listeners = append(c.listeners, listener...) +func (v *Layer) AddLayerChangeListener(listener ...LayerChangeListener) { + v.listeners = append(v.listeners, listener...) } -func (c *Layer) notifyLayerChangeListeners() error { - bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes() +func (v *Layer) notifyLayerChangeListeners() error { + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes() selection := viewmodel.LayerSelection{ - Layer: c.CurrentLayer(), + Layer: v.CurrentLayer(), BottomTreeStart: bottomTreeStart, BottomTreeStop: bottomTreeStop, TopTreeStart: topTreeStart, TopTreeStop: topTreeStop, } - for _, listener := range c.listeners { + for _, listener := range v.listeners { err := listener(selection) if err != nil { logrus.Errorf("notifyLayerChangeListeners error: %+v", err) @@ -79,189 +76,190 @@ func (c *Layer) notifyLayerChangeListeners() error { return nil } -func (c *Layer) Name() string { - return c.name +func (v *Layer) Name() string { + return v.name } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (c *Layer) Setup(v *gocui.View, header *gocui.View) error { +func (v *Layer) Setup(view *gocui.View, header *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) // set controller options - c.view = v - c.view.Editable = false - c.view.Wrap = false - c.view.Frame = false + v.view = view + v.view.Editable = false + v.view.Wrap = false + v.view.Frame = false - c.header = header - c.header.Editable = false - c.header.Wrap = false - c.header.Frame = false + v.header = header + v.header.Editable = false + v.header.Wrap = false + v.header.Frame = false var infos = []key.BindingInfo{ { ConfigKeys: []string{"keybinding.compare-layer"}, - OnAction: func() error { return c.setCompareMode(CompareLayer) }, - IsSelected: func() bool { return c.CompareMode == CompareLayer }, + OnAction: func() error { return v.setCompareMode(CompareLayer) }, + IsSelected: func() bool { return v.CompareMode == CompareLayer }, Display: "Show layer changes", }, { ConfigKeys: []string{"keybinding.compare-all"}, - OnAction: func() error { return c.setCompareMode(CompareAll) }, - IsSelected: func() bool { return c.CompareMode == CompareAll }, + OnAction: func() error { return v.setCompareMode(CompareAll) }, + IsSelected: func() bool { return v.CompareMode == CompareAll }, Display: "Show aggregated changes", }, { Key: gocui.KeyArrowDown, Modifier: gocui.ModNone, - OnAction: c.CursorDown, + OnAction: v.CursorDown, }, { Key: gocui.KeyArrowUp, Modifier: gocui.ModNone, - OnAction: c.CursorUp, + OnAction: v.CursorUp, }, { Key: gocui.KeyArrowLeft, Modifier: gocui.ModNone, - OnAction: c.CursorUp, + OnAction: v.CursorUp, }, { Key: gocui.KeyArrowRight, Modifier: gocui.ModNone, - OnAction: c.CursorDown, + OnAction: v.CursorDown, }, { ConfigKeys: []string{"keybinding.page-up"}, - OnAction: c.PageUp, + OnAction: v.PageUp, }, { ConfigKeys: []string{"keybinding.page-down"}, - OnAction: c.PageDown, + OnAction: v.PageDown, }, } - helpKeys, err := key.GenerateBindings(c.gui, c.name, infos) + helpKeys, err := key.GenerateBindings(v.gui, v.name, infos) if err != nil { return err } - c.helpKeys = helpKeys + v.helpKeys = helpKeys - return c.Render() + return v.Render() } // height obtains the height of the current pane (taking into account the lost space due to the header). -func (c *Layer) height() uint { - _, height := c.view.Size() +func (v *Layer) height() uint { + _, height := v.view.Size() return uint(height - 1) } // IsVisible indicates if the layer view pane is currently initialized. -func (c *Layer) IsVisible() bool { - return c != nil +func (v *Layer) IsVisible() bool { + return v != nil } // PageDown moves to next page putting the cursor on top -func (c *Layer) PageDown() error { - step := int(c.height()) + 1 - targetLayerIndex := c.LayerIndex + step +func (v *Layer) PageDown() error { + step := int(v.height()) + 1 + targetLayerIndex := v.LayerIndex + step - if targetLayerIndex > len(c.Layers) { - step -= targetLayerIndex - (len(c.Layers) - 1) + if targetLayerIndex > len(v.Layers) { + step -= targetLayerIndex - (len(v.Layers) - 1) } if step > 0 { - err := CursorStep(c.gui, c.view, step) + err := CursorStep(v.gui, v.view, step) if err == nil { - return c.SetCursor(c.LayerIndex + step) + return v.SetCursor(v.LayerIndex + step) } } return nil } // PageUp moves to previous page putting the cursor on top -func (c *Layer) PageUp() error { - step := int(c.height()) + 1 - targetLayerIndex := c.LayerIndex - step +func (v *Layer) PageUp() error { + step := int(v.height()) + 1 + targetLayerIndex := v.LayerIndex - step if targetLayerIndex < 0 { step += targetLayerIndex } if step > 0 { - err := CursorStep(c.gui, c.view, -step) + err := CursorStep(v.gui, v.view, -step) if err == nil { - return c.SetCursor(c.LayerIndex - step) + return v.SetCursor(v.LayerIndex - step) } } return nil } // 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 := CursorDown(c.gui, c.view) +func (v *Layer) CursorDown() error { + if v.LayerIndex < len(v.Layers) { + err := CursorDown(v.gui, v.view) if err == nil { - return c.SetCursor(c.LayerIndex + 1) + return v.SetCursor(v.LayerIndex + 1) } } return nil } // CursorUp moves the cursor up in the layer pane (selecting a lower layer). -func (c *Layer) CursorUp() error { - if c.LayerIndex > 0 { - err := CursorUp(c.gui, c.view) +func (v *Layer) CursorUp() error { + if v.LayerIndex > 0 { + err := CursorUp(v.gui, v.view) if err == nil { - return c.SetCursor(c.LayerIndex - 1) + return v.SetCursor(v.LayerIndex - 1) } } return nil } // 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 := c.notifyLayerChangeListeners() +func (v *Layer) SetCursor(layer int) error { + v.LayerIndex = layer + err := v.notifyLayerChangeListeners() if err != nil { return err } - return c.Render() + return v.Render() } // CurrentLayer returns the Layer object currently selected. -func (c *Layer) CurrentLayer() *image.Layer { - return c.Layers[c.LayerIndex] +func (v *Layer) CurrentLayer() *image.Layer { + return v.Layers[v.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 - return c.notifyLayerChangeListeners() +func (v *Layer) setCompareMode(compareMode CompareType) error { + v.CompareMode = compareMode + return v.notifyLayerChangeListeners() } // getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) -func (c *Layer) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { - bottomTreeStart = c.CompareStartIndex - topTreeStop = c.LayerIndex - - if c.LayerIndex == c.CompareStartIndex { - bottomTreeStop = c.LayerIndex - topTreeStart = c.LayerIndex - } else if c.CompareMode == CompareLayer { - bottomTreeStop = c.LayerIndex - 1 - topTreeStart = c.LayerIndex +func (v *Layer) getCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { + bottomTreeStart = v.CompareStartIndex + topTreeStop = v.LayerIndex + + if v.LayerIndex == v.CompareStartIndex { + bottomTreeStop = v.LayerIndex + topTreeStart = v.LayerIndex + } else if v.CompareMode == CompareLayer { + bottomTreeStop = v.LayerIndex - 1 + topTreeStart = v.LayerIndex } else { - bottomTreeStop = c.CompareStartIndex - topTreeStart = c.CompareStartIndex + 1 + bottomTreeStop = v.CompareStartIndex + topTreeStart = v.CompareStartIndex + 1 } return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop } // renderCompareBar returns the formatted string for the given layer. -func (c *Layer) renderCompareBar(layerIdx int) string { - bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := c.getCompareIndexes() +func (v *Layer) renderCompareBar(layerIdx int) string { + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes() result := " " if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { @@ -274,44 +272,52 @@ func (c *Layer) renderCompareBar(layerIdx int) string { return result } +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Layer) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() +} + // Update refreshes the state objects for future rendering (currently does nothing). -func (c *Layer) Update() error { +func (v *Layer) Update() error { return nil } // Render flushes the state objects to the screen. The layers pane reports: // 1. the layers of the image + metadata // 2. the current selected image -func (c *Layer) Render() error { +func (v *Layer) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) // indicate when selected title := "Layers" - if c.gui.CurrentView() == c.view { - title = "● " + title - } + isSelected := v.gui.CurrentView() == v.view - c.gui.Update(func(g *gocui.Gui) error { + v.gui.Update(func(g *gocui.Gui) error { // update header - c.header.Clear() + v.header.Clear() width, _ := g.Size() - headerStr := fmt.Sprintf("[%s]%s\n", title, strings.Repeat("─", width*2)) + headerStr := format.RenderHeader(title, width, isSelected) headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command") - _, err := fmt.Fprintln(c.header, format.Header(vtclean.Clean(headerStr, false))) + _, err := fmt.Fprintln(v.header, headerStr) if err != nil { return err } // update contents - c.view.Clear() - for idx, layer := range c.Layers { + v.view.Clear() + for idx, layer := range v.Layers { layerStr := layer.String() - compareBar := c.renderCompareBar(idx) + compareBar := v.renderCompareBar(idx) - if idx == c.LayerIndex { - _, err = fmt.Fprintln(c.view, compareBar+" "+format.Selected(layerStr)) + if idx == v.LayerIndex { + _, err = fmt.Fprintln(v.view, compareBar+" "+format.Selected(layerStr)) } else { - _, err = fmt.Fprintln(c.view, compareBar+" "+layerStr) + _, err = fmt.Fprintln(v.view, compareBar+" "+layerStr) } if err != nil { @@ -326,9 +332,9 @@ func (c *Layer) Render() error { } // KeyHelp indicates all the possible actions a user can take while the current pane is selected. -func (c *Layer) KeyHelp() string { +func (v *Layer) KeyHelp() string { var help string - for _, binding := range c.helpKeys { + for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help diff --git a/runtime/ui/view/renderer.go b/runtime/ui/view/renderer.go index c3fadf58..d6c908f6 100644 --- a/runtime/ui/view/renderer.go +++ b/runtime/ui/view/renderer.go @@ -5,5 +5,8 @@ type Renderer interface { Update() error Render() error IsVisible() bool +} + +type Helper interface { KeyHelp() string } diff --git a/runtime/ui/view/status.go b/runtime/ui/view/status.go index bdd03638..87a4b46a 100644 --- a/runtime/ui/view/status.go +++ b/runtime/ui/view/status.go @@ -5,6 +5,7 @@ import ( "github.com/sirupsen/logrus" "github.com/wagoodman/dive/runtime/ui/format" "github.com/wagoodman/dive/runtime/ui/key" + "github.com/wagoodman/dive/utils" "strings" "github.com/jroimartin/gocui" @@ -17,76 +18,80 @@ type Status struct { gui *gocui.Gui view *gocui.View - selectedView Renderer + selectedView Helper + requestedHeight int helpKeys []*key.Binding } -// NewStatusView creates a new view object attached the the global [gocui] screen object. -func NewStatusView(name string, gui *gocui.Gui) (controller *Status) { +// newStatusView creates a new view object attached the the global [gocui] screen object. +func newStatusView(gui *gocui.Gui) (controller *Status) { controller = new(Status) // populate main fields - controller.name = name + controller.name = "status" controller.gui = gui controller.helpKeys = make([]*key.Binding, 0) + controller.requestedHeight = 1 return controller } -func (c *Status) SetCurrentView(r Renderer) { - c.selectedView = r +func (v *Status) SetCurrentView(r Helper) { + v.selectedView = r } -func (c *Status) Name() string { - return c.name +func (v *Status) Name() string { + return v.name } -func (c *Status) AddHelpKeys(keys ...*key.Binding) { - c.helpKeys = append(c.helpKeys, keys...) +func (v *Status) AddHelpKeys(keys ...*key.Binding) { + v.helpKeys = append(v.helpKeys, keys...) } // Setup initializes the UI concerns within the context of a global [gocui] view object. -func (c *Status) Setup(v *gocui.View, header *gocui.View) error { +func (v *Status) Setup(view *gocui.View) error { + logrus.Tracef("view.Setup() %s", v.Name()) // set controller options - c.view = v - c.view.Frame = false + v.view = view + v.view.Frame = false - return c.Render() + return v.Render() } // IsVisible indicates if the status view pane is currently initialized. -func (c *Status) IsVisible() bool { - return c != nil +func (v *Status) IsVisible() bool { + return v != nil } -// CursorDown moves the cursor down in the details pane (currently indicates nothing). -func (c *Status) CursorDown() error { - return nil -} - -// CursorUp moves the cursor up in the details pane (currently indicates nothing). -func (c *Status) CursorUp() error { +// Update refreshes the state objects for future rendering (currently does nothing). +func (v *Status) Update() error { return nil } -// Update refreshes the state objects for future rendering (currently does nothing). -func (c *Status) Update() error { - return nil +// OnLayoutChange is called whenever the screen dimensions are changed +func (v *Status) OnLayoutChange() error { + err := v.Update() + if err != nil { + return err + } + return v.Render() } // Render flushes the state objects to the screen. -func (c *Status) Render() error { - c.gui.Update(func(g *gocui.Gui) error { - c.view.Clear() +func (v *Status) Render() error { + logrus.Tracef("view.Render() %s", v.Name()) + + v.gui.Update(func(g *gocui.Gui) error { + v.view.Clear() var selectedHelp string - if c.selectedView != nil { - selectedHelp = c.selectedView.KeyHelp() + if v.selectedView != nil { + selectedHelp = v.selectedView.KeyHelp() } - _, err := fmt.Fprintln(c.view, c.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) + _, err := fmt.Fprintln(v.view, v.KeyHelp()+selectedHelp+format.StatusNormal("▏"+strings.Repeat(" ", 1000))) if err != nil { logrus.Debug("unable to write to buffer: ", err) } @@ -97,10 +102,28 @@ func (c *Status) Render() error { } // KeyHelp indicates all the possible global actions a user can take when any pane is selected. -func (c *Status) KeyHelp() string { +func (v *Status) KeyHelp() string { var help string - for _, binding := range c.helpKeys { + for _, binding := range v.helpKeys { help += binding.RenderKeyHelp() } return help } + +func (v *Status) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { + logrus.Tracef("view.Layout(minX: %d, minY: %d, maxX: %d, maxY: %d) %s", minX, minY, maxX, maxY, v.Name()) + + view, viewErr := g.SetView(v.Name(), minX, minY, maxX, maxY) + if utils.IsNewView(viewErr) { + err := v.Setup(view) + if err != nil { + logrus.Error("unable to setup status controller", err) + return err + } + } + return nil +} + +func (v *Status) RequestedSize(available int) *int { + return &v.requestedHeight +} diff --git a/runtime/ui/view/views.go b/runtime/ui/view/views.go new file mode 100644 index 00000000..71d37874 --- /dev/null +++ b/runtime/ui/view/views.go @@ -0,0 +1,59 @@ +package view + +import ( + "github.com/jroimartin/gocui" + "github.com/wagoodman/dive/dive/filetree" + "github.com/wagoodman/dive/dive/image" +) + +type Views struct { + Tree *FileTree + Layer *Layer + Status *Status + Filter *Filter + Details *Details + Debug *Debug +} + +func NewViews(g *gocui.Gui, analysis *image.AnalysisResult, cache filetree.Comparer) (*Views, error) { + Layer, err := newLayerView(g, analysis.Layers) + if err != nil { + return nil, err + } + + treeStack := analysis.RefTrees[0] + Tree, err := newFileTreeView(g, treeStack, analysis.RefTrees, cache) + if err != nil { + return nil, err + } + + Status := newStatusView(g) + + // set the layer view as the first selected view + Status.SetCurrentView(Layer) + + Filter := newFilterView(g) + + Details := newDetailsView(g, analysis.Efficiency, analysis.Inefficiencies, analysis.SizeBytes) + + Debug := newDebugView(g) + + return &Views{ + Tree: Tree, + Layer: Layer, + Status: Status, + Filter: Filter, + Details: Details, + Debug: Debug, + }, nil +} + +func (views *Views) All() []Renderer { + return []Renderer{ + views.Tree, + views.Layer, + views.Status, + views.Filter, + views.Details, + } +} diff --git a/utils/view.go b/utils/view.go new file mode 100644 index 00000000..270da4b7 --- /dev/null +++ b/utils/view.go @@ -0,0 +1,20 @@ +package utils + +import ( + "github.com/jroimartin/gocui" + "github.com/sirupsen/logrus" +) + +// isNewView determines if a view has already been created based on the set of errors given (a bit hokie) +func IsNewView(errs ...error) bool { + for _, err := range errs { + if err == nil { + return false + } + if err != gocui.ErrUnknownView { + logrus.Errorf("IsNewView() unexpected error: %+v", err) + return true + } + } + return true +}