diff --git a/runtime/ui/controller.go b/runtime/ui/controller.go index 1a801df..d9ca330 100644 --- a/runtime/ui/controller.go +++ b/runtime/ui/controller.go @@ -90,7 +90,7 @@ func (c *Controller) onLayerChange(selection viewmodel.LayerSelection) error { return err } - if c.views.Layer.CompareMode == view.CompareAll { + if c.views.Layer.CompareMode() == viewmodel.CompareAllLayers { c.views.Tree.SetTitle("Aggregated Layer Contents") } else { c.views.Tree.SetTitle("Current Layer Contents") diff --git a/runtime/ui/format/format.go b/runtime/ui/format/format.go index 0f54910..6a7c671 100644 --- a/runtime/ui/format/format.go +++ b/runtime/ui/format/format.go @@ -58,17 +58,32 @@ func init() { CompareBottom = color.New(color.BgGreen).SprintFunc() } +func RenderNoHeader(width int, selected bool) string { + if selected { + return strings.Repeat(selectedFillStr, width) + } + return strings.Repeat(fillStr, width) +} + 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)) + repeatCount := width - bodyLen - 2 + if repeatCount < 0 { + repeatCount = 0 + } + return fmt.Sprintf("%s%s%s%s\n", selectedLeftBracketStr, body, selectedRightBracketStr, strings.Repeat(selectedFillStr, repeatCount)) //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)) + repeatCount := width - bodyLen - 2 + if repeatCount < 0 { + repeatCount = 0 + } + return fmt.Sprintf("%s%s%s%s\n", leftBracketStr, body, rightBracketStr, strings.Repeat(fillStr, repeatCount)) } func RenderHelpKey(control, title string, selected bool) string { diff --git a/runtime/ui/layout/compound/layer_details_column.go b/runtime/ui/layout/compound/layer_details_column.go index 754b6f0..1941edf 100644 --- a/runtime/ui/layout/compound/layer_details_column.go +++ b/runtime/ui/layout/compound/layer_details_column.go @@ -8,8 +8,9 @@ import ( ) type LayerDetailsCompoundLayout struct { - layer *view.Layer - details *view.Details + layer *view.Layer + details *view.Details + constrainRealEstate bool } func NewLayerDetailsCompoundLayout(layer *view.Layer, details *view.Details) *LayerDetailsCompoundLayout { @@ -48,7 +49,7 @@ func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, max // header + border layerHeaderHeight := 2 - layersHeight := len(cl.layer.Layers) + layerHeaderHeight + 1 // layers + header + base image layer row + layersHeight := cl.layer.LayerCount() + layerHeaderHeight + 1 // layers + header + base image layer row maxLayerHeight := int(0.75 * float64(maxY)) if layersHeight > maxLayerHeight { layersHeight = maxLayerHeight @@ -80,12 +81,30 @@ func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, max // 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) + v, _ := g.View(cl.details.Name()) + if v != nil { + // the view exists already! - // 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) + // don't show the details pane when there isn't enough room on the screen + if cl.constrainRealEstate { + // take note: deleting a view will invoke layout again, so ensure this call is protected from an infinite loop + err := g.DeleteView(cl.details.Name()) + if err != nil { + return err + } + // take note: deleting a view will invoke layout again, so ensure this call is protected from an infinite loop + err = g.DeleteView(cl.details.Name() + "header") + if err != nil { + return err + } + + return nil + } + + } + + header, headerErr = g.SetView(cl.details.Name()+"header", minX, detailsMinY, maxX, detailsMinY+detailsHeaderHeight) + main, viewErr = g.SetView(cl.details.Name(), minX, detailsMinY+detailsHeaderHeight, maxX, maxY) if utils.IsNewView(viewErr, headerErr) { err := cl.details.Setup(main, header) @@ -98,6 +117,16 @@ func (cl *LayerDetailsCompoundLayout) Layout(g *gocui.Gui, minX, minY, maxX, max } func (cl *LayerDetailsCompoundLayout) RequestedSize(available int) *int { + // "available" is the entire screen real estate, so we can guess when its a bit too small and take action. + // This isn't perfect, but it gets the job done for now without complicated layout constraint solvers + if available < 90 { + cl.layer.ConstrainLayout() + cl.constrainRealEstate = true + size := 8 + return &size + } + cl.layer.ExpandLayout() + cl.constrainRealEstate = false return nil } diff --git a/runtime/ui/layout/manager.go b/runtime/ui/layout/manager.go index 6bfe0aa..460cbea 100644 --- a/runtime/ui/layout/manager.go +++ b/runtime/ui/layout/manager.go @@ -5,6 +5,8 @@ import ( "github.com/sirupsen/logrus" ) +type Constraint func(int) int + type Manager struct { lastX, lastY int lastHeaderArea, lastFooterArea, lastColumnArea Area @@ -113,6 +115,13 @@ func (lm *Manager) planAndLayoutColumns(g *gocui.Gui, area Area) (Area, error) { } } + // at least one column must have a variable width, force the last column to be variable if there are no + // variable columns + if variableColumns == 0 { + variableColumns = 1 + widths[len(widths)-1] = -1 + } + defaultWidth := availableWidth / variableColumns // second pass: layout columns left to right (based off predetermined widths) diff --git a/runtime/ui/view/filetree.go b/runtime/ui/view/filetree.go index a499aed..97e6f7b 100644 --- a/runtime/ui/view/filetree.go +++ b/runtime/ui/view/filetree.go @@ -13,13 +13,6 @@ import ( "regexp" ) -const ( - CompareLayer CompareType = iota - CompareAll -) - -type CompareType int - type ViewOptionChangeListener func() error // FileTree holds the UI objects and data models for populating the right pane. Specifically the pane that @@ -77,10 +70,6 @@ func (v *FileTree) Name() string { return v.name } -func (v *FileTree) areAttributesVisible() bool { - return v.vm.ShowAttributes -} - // Setup initializes the UI concerns within the context of a global [gocui] view object. func (v *FileTree) Setup(view *gocui.View, header *gocui.View) error { logrus.Tracef("view.Setup() %s", v.Name()) @@ -377,7 +366,7 @@ func (v *FileTree) Render() error { if v.vm.ShowAttributes { headerStr += fmt.Sprintf(filetree.AttributeFormat+" %s", "P", "ermission", "UID:GID", "Size", "Filetree") } - _, _ = fmt.Fprintln(v.header, headerStr, false) + _, _ = fmt.Fprintln(v.header, headerStr) // update the contents v.view.Clear() @@ -404,9 +393,19 @@ func (v *FileTree) KeyHelp() string { 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() { + + // make the layout responsive to the available realestate. Make more room for the main content by hiding auxillary + // content when there is not enough room + if maxX-minX < 60 { + v.vm.ConstrainLayout() + } else { + v.vm.ExpandLayout() + } + + if v.vm.ShowAttributes { attributeRowSize = 1 } + // header + attribute header headerSize := 1 + attributeRowSize // note: maxY needs to account for the (invisible) border, thus a +1 @@ -425,6 +424,7 @@ func (v *FileTree) Layout(g *gocui.Gui, minX, minY, maxX, maxY int) error { } func (v *FileTree) RequestedSize(available int) *int { - var requestedWidth = int(float64(available) * (1.0 - v.requestedWidthRatio)) - return &requestedWidth + //var requestedWidth = int(float64(available) * (1.0 - v.requestedWidthRatio)) + //return &requestedWidth + return nil } diff --git a/runtime/ui/view/layer.go b/runtime/ui/view/layer.go index 578689d..08472fa 100644 --- a/runtime/ui/view/layer.go +++ b/runtime/ui/view/layer.go @@ -11,19 +11,15 @@ import ( "github.com/wagoodman/dive/runtime/ui/viewmodel" ) -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 { - name string - gui *gocui.Gui - view *gocui.View - header *gocui.View - LayerIndex int - Layers []*image.Layer - CompareMode CompareType - CompareStartIndex int + name string + gui *gocui.Gui + view *gocui.View + header *gocui.View + vm *viewmodel.LayerSetState + constrainedRealEstate bool listeners []LayerChangeListener @@ -39,17 +35,20 @@ func newLayerView(gui *gocui.Gui, layers []*image.Layer) (controller *Layer, err // populate main fields controller.name = "layer" controller.gui = gui - controller.Layers = layers + + var compareMode viewmodel.LayerCompareMode switch mode := viper.GetBool("layer.show-aggregated-changes"); mode { case true: - controller.CompareMode = CompareAll + compareMode = viewmodel.CompareAllLayers case false: - controller.CompareMode = CompareLayer + compareMode = viewmodel.CompareSingleLayer default: return nil, fmt.Errorf("unknown layer.show-aggregated-changes value: %v", mode) } + controller.vm = viewmodel.NewLayerSetState(layers, compareMode) + return controller, err } @@ -58,7 +57,7 @@ func (v *Layer) AddLayerChangeListener(listener ...LayerChangeListener) { } func (v *Layer) notifyLayerChangeListeners() error { - bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes() + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.vm.GetCompareIndexes() selection := viewmodel.LayerSelection{ Layer: v.CurrentLayer(), BottomTreeStart: bottomTreeStart, @@ -98,14 +97,14 @@ func (v *Layer) Setup(view *gocui.View, header *gocui.View) error { var infos = []key.BindingInfo{ { ConfigKeys: []string{"keybinding.compare-layer"}, - OnAction: func() error { return v.setCompareMode(CompareLayer) }, - IsSelected: func() bool { return v.CompareMode == CompareLayer }, + OnAction: func() error { return v.setCompareMode(viewmodel.CompareSingleLayer) }, + IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareSingleLayer }, Display: "Show layer changes", }, { ConfigKeys: []string{"keybinding.compare-all"}, - OnAction: func() error { return v.setCompareMode(CompareAll) }, - IsSelected: func() bool { return v.CompareMode == CompareAll }, + OnAction: func() error { return v.setCompareMode(viewmodel.CompareAllLayers) }, + IsSelected: func() bool { return v.vm.CompareMode == viewmodel.CompareAllLayers }, Display: "Show aggregated changes", }, { @@ -153,6 +152,10 @@ func (v *Layer) height() uint { return uint(height - 1) } +func (v *Layer) CompareMode() viewmodel.LayerCompareMode { + return v.vm.CompareMode +} + // IsVisible indicates if the layer view pane is currently initialized. func (v *Layer) IsVisible() bool { return v != nil @@ -161,16 +164,16 @@ func (v *Layer) IsVisible() bool { // PageDown moves to next page putting the cursor on top func (v *Layer) PageDown() error { step := int(v.height()) + 1 - targetLayerIndex := v.LayerIndex + step + targetLayerIndex := v.vm.LayerIndex + step - if targetLayerIndex > len(v.Layers) { - step -= targetLayerIndex - (len(v.Layers) - 1) + if targetLayerIndex > len(v.vm.Layers) { + step -= targetLayerIndex - (len(v.vm.Layers) - 1) } if step > 0 { err := CursorStep(v.gui, v.view, step) if err == nil { - return v.SetCursor(v.LayerIndex + step) + return v.SetCursor(v.vm.LayerIndex + step) } } return nil @@ -179,7 +182,7 @@ func (v *Layer) PageDown() error { // PageUp moves to previous page putting the cursor on top func (v *Layer) PageUp() error { step := int(v.height()) + 1 - targetLayerIndex := v.LayerIndex - step + targetLayerIndex := v.vm.LayerIndex - step if targetLayerIndex < 0 { step += targetLayerIndex @@ -188,7 +191,7 @@ func (v *Layer) PageUp() error { if step > 0 { err := CursorStep(v.gui, v.view, -step) if err == nil { - return v.SetCursor(v.LayerIndex - step) + return v.SetCursor(v.vm.LayerIndex - step) } } return nil @@ -196,10 +199,10 @@ func (v *Layer) PageUp() error { // CursorDown moves the cursor down in the layer pane (selecting a higher layer). func (v *Layer) CursorDown() error { - if v.LayerIndex < len(v.Layers) { + if v.vm.LayerIndex < len(v.vm.Layers) { err := CursorDown(v.gui, v.view) if err == nil { - return v.SetCursor(v.LayerIndex + 1) + return v.SetCursor(v.vm.LayerIndex + 1) } } return nil @@ -207,10 +210,10 @@ func (v *Layer) CursorDown() error { // CursorUp moves the cursor up in the layer pane (selecting a lower layer). func (v *Layer) CursorUp() error { - if v.LayerIndex > 0 { + if v.vm.LayerIndex > 0 { err := CursorUp(v.gui, v.view) if err == nil { - return v.SetCursor(v.LayerIndex - 1) + return v.SetCursor(v.vm.LayerIndex - 1) } } return nil @@ -218,7 +221,7 @@ func (v *Layer) CursorUp() error { // SetCursor resets the cursor and orients the file tree view based on the given layer index. func (v *Layer) SetCursor(layer int) error { - v.LayerIndex = layer + v.vm.LayerIndex = layer err := v.notifyLayerChangeListeners() if err != nil { return err @@ -229,37 +232,18 @@ func (v *Layer) SetCursor(layer int) error { // CurrentLayer returns the Layer object currently selected. func (v *Layer) CurrentLayer() *image.Layer { - return v.Layers[v.LayerIndex] + return v.vm.Layers[v.vm.LayerIndex] } // setCompareMode switches the layer comparison between a single-layer comparison to an aggregated comparison. -func (v *Layer) setCompareMode(compareMode CompareType) error { - v.CompareMode = compareMode +func (v *Layer) setCompareMode(compareMode viewmodel.LayerCompareMode) error { + v.vm.CompareMode = compareMode return v.notifyLayerChangeListeners() } -// getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) -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 = v.CompareStartIndex - topTreeStart = v.CompareStartIndex + 1 - } - - return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop -} - // renderCompareBar returns the formatted string for the given layer. func (v *Layer) renderCompareBar(layerIdx int) string { - bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.getCompareIndexes() + bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop := v.vm.GetCompareIndexes() result := " " if layerIdx >= bottomTreeStart && layerIdx <= bottomTreeStop { @@ -272,6 +256,20 @@ func (v *Layer) renderCompareBar(layerIdx int) string { return result } +func (v *Layer) ConstrainLayout() { + if !v.constrainedRealEstate { + logrus.Debugf("constraining layer layout") + v.constrainedRealEstate = true + } +} + +func (v *Layer) ExpandLayout() { + if v.constrainedRealEstate { + logrus.Debugf("expanding layer layout") + v.constrainedRealEstate = false + } +} + // OnLayoutChange is called whenever the screen dimensions are changed func (v *Layer) OnLayoutChange() error { err := v.Update() @@ -297,24 +295,40 @@ func (v *Layer) Render() error { isSelected := v.gui.CurrentView() == v.view v.gui.Update(func(g *gocui.Gui) error { + var err error // update header v.header.Clear() width, _ := g.Size() - headerStr := format.RenderHeader(title, width, isSelected) - headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command") - _, err := fmt.Fprintln(v.header, headerStr) - if err != nil { - return err + if v.constrainedRealEstate { + headerStr := format.RenderNoHeader(width, isSelected) + headerStr += fmt.Sprintf("\nLayer") + _, err := fmt.Fprintln(v.header, headerStr) + if err != nil { + return err + } + } else { + headerStr := format.RenderHeader(title, width, isSelected) + headerStr += fmt.Sprintf("Cmp"+image.LayerFormat, "Size", "Command") + _, err := fmt.Fprintln(v.header, headerStr) + if err != nil { + return err + } } // update contents v.view.Clear() - for idx, layer := range v.Layers { + for idx, layer := range v.vm.Layers { + + var layerStr string + if v.constrainedRealEstate { + layerStr = fmt.Sprintf("%-4d", layer.Index) + } else { + layerStr = layer.String() + } - layerStr := layer.String() compareBar := v.renderCompareBar(idx) - if idx == v.LayerIndex { + if idx == v.vm.LayerIndex { _, err = fmt.Fprintln(v.view, compareBar+" "+format.Selected(layerStr)) } else { _, err = fmt.Fprintln(v.view, compareBar+" "+layerStr) @@ -331,6 +345,10 @@ func (v *Layer) Render() error { return nil } +func (v *Layer) LayerCount() int { + return len(v.vm.Layers) +} + // KeyHelp indicates all the possible actions a user can take while the current pane is selected. func (v *Layer) KeyHelp() string { var help string diff --git a/runtime/ui/view/layer_change_listener.go b/runtime/ui/view/layer_change_listener.go new file mode 100644 index 0000000..3a7096f --- /dev/null +++ b/runtime/ui/view/layer_change_listener.go @@ -0,0 +1,5 @@ +package view + +import "github.com/wagoodman/dive/runtime/ui/viewmodel" + +type LayerChangeListener func(viewmodel.LayerSelection) error diff --git a/runtime/ui/viewmodel/filetree.go b/runtime/ui/viewmodel/filetree.go index fe7f00d..2240ab3 100644 --- a/runtime/ui/viewmodel/filetree.go +++ b/runtime/ui/viewmodel/filetree.go @@ -21,12 +21,15 @@ type FileTree struct { RefTrees []*filetree.FileTree cache filetree.Comparer - CollapseAll bool - ShowAttributes bool - HiddenDiffTypes []bool - TreeIndex int - bufferIndex int - bufferIndexLowerBound int + constrainedRealEstate bool + + CollapseAll bool + ShowAttributes bool + unconstrainedShowAttributes bool + HiddenDiffTypes []bool + TreeIndex int + bufferIndex int + bufferIndexLowerBound int refHeight int refWidth int @@ -40,6 +43,7 @@ func NewFileTreeViewModel(tree *filetree.FileTree, refTrees []*filetree.FileTree // populate main fields treeViewModel.ShowAttributes = viper.GetBool("filetree.show-attributes") + treeViewModel.unconstrainedShowAttributes = treeViewModel.ShowAttributes treeViewModel.CollapseAll = viper.GetBool("filetree.collapse-dir") treeViewModel.ModelTree = tree treeViewModel.RefTrees = refTrees @@ -351,8 +355,29 @@ func (vm *FileTree) ToggleCollapseAll() error { return nil } +func (vm *FileTree) ConstrainLayout() { + if !vm.constrainedRealEstate { + logrus.Debugf("constraining filetree layout") + vm.constrainedRealEstate = true + vm.unconstrainedShowAttributes = vm.ShowAttributes + vm.ShowAttributes = false + } +} + +func (vm *FileTree) ExpandLayout() { + if vm.constrainedRealEstate { + logrus.Debugf("expanding filetree layout") + vm.ShowAttributes = vm.unconstrainedShowAttributes + vm.constrainedRealEstate = false + } +} + // ToggleCollapse will collapse/expand the selected FileNode. func (vm *FileTree) ToggleAttributes() error { + // ignore any attempt to show the attributes when the layout is constrained + if vm.constrainedRealEstate { + return nil + } vm.ShowAttributes = !vm.ShowAttributes return nil } diff --git a/runtime/ui/viewmodel/layer_compare.go b/runtime/ui/viewmodel/layer_compare.go new file mode 100644 index 0000000..2313c84 --- /dev/null +++ b/runtime/ui/viewmodel/layer_compare.go @@ -0,0 +1,8 @@ +package viewmodel + +const ( + CompareSingleLayer LayerCompareMode = iota + CompareAllLayers +) + +type LayerCompareMode int diff --git a/runtime/ui/viewmodel/layer_set_state.go b/runtime/ui/viewmodel/layer_set_state.go new file mode 100644 index 0000000..3f02817 --- /dev/null +++ b/runtime/ui/viewmodel/layer_set_state.go @@ -0,0 +1,36 @@ +package viewmodel + +import "github.com/wagoodman/dive/dive/image" + +type LayerSetState struct { + LayerIndex int + Layers []*image.Layer + CompareMode LayerCompareMode + CompareStartIndex int +} + +func NewLayerSetState(layers []*image.Layer, compareMode LayerCompareMode) *LayerSetState { + return &LayerSetState{ + Layers: layers, + CompareMode: compareMode, + } +} + +// getCompareIndexes determines the layer boundaries to use for comparison (based on the current compare mode) +func (state *LayerSetState) GetCompareIndexes() (bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop int) { + bottomTreeStart = state.CompareStartIndex + topTreeStop = state.LayerIndex + + if state.LayerIndex == state.CompareStartIndex { + bottomTreeStop = state.LayerIndex + topTreeStart = state.LayerIndex + } else if state.CompareMode == CompareSingleLayer { + bottomTreeStop = state.LayerIndex - 1 + topTreeStart = state.LayerIndex + } else { + bottomTreeStop = state.CompareStartIndex + topTreeStart = state.CompareStartIndex + 1 + } + + return bottomTreeStart, bottomTreeStop, topTreeStart, topTreeStop +}