Skip to content

Commit f41d866

Browse files
improved/reworked form alignment
1 parent 2e844f9 commit f41d866

6 files changed

Lines changed: 228 additions & 13 deletions

File tree

ui/tui/models/helpers/form/form.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ func (f Form[T]) View() string {
174174
activeRowHeight := lipgloss.Height(rowViews[activeRowIndex])
175175

176176
// combine rendered rows and render in limited viewport
177-
return util.RenderContentInViewportAligned(
177+
return util.RenderContentInViewportAlignY(
178178
lipgloss.JoinVertical(lipgloss.Left, rowViews...),
179179
f.size.Height,
180180
activeRowOffset,

ui/tui/util/keys/common_keys.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,31 @@ func Right() key.Binding {
6262
)
6363
}
6464

65+
func UpArrow() key.Binding {
66+
return key.NewBinding(
67+
key.WithKeys("up"),
68+
key.WithHelp("↑", "up"),
69+
)
70+
}
71+
func DownArrow() key.Binding {
72+
return key.NewBinding(
73+
key.WithKeys("down"),
74+
key.WithHelp("↓", "down"),
75+
)
76+
}
77+
func LeftArrow() key.Binding {
78+
return key.NewBinding(
79+
key.WithKeys("left"),
80+
key.WithHelp("←", "left"),
81+
)
82+
}
83+
func RightArrow() key.Binding {
84+
return key.NewBinding(
85+
key.WithKeys("right"),
86+
key.WithHelp("→", "right"),
87+
)
88+
}
89+
6590
func LeftBack() key.Binding {
6691
return key.NewBinding(
6792
key.WithKeys("left", "backspace", "esc"),

ui/tui/util/size.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,9 @@ package util
55

66
import tea "github.com/charmbracelet/bubbletea"
77

8-
type Size struct {
9-
Width int
10-
Height int
11-
}
8+
type Vec[T comparable] struct{ X, Y T }
9+
10+
type Size struct{ Width, Height int }
1211

1312
func (s *Size) UpdateFromMsg(msg tea.Msg) bool {
1413
if msg, ok := msg.(tea.WindowSizeMsg); ok {

ui/tui/util/update_tea_model.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
package util
55

66
import (
7+
"fmt"
8+
79
tea "github.com/charmbracelet/bubbletea"
810
"github.com/toeirei/keymaster/util/slicest"
911
)
@@ -26,8 +28,7 @@ func UpdateTeaModelInplace[M any](msg tea.Msg, model *M) tea.Cmd {
2628
return cmd
2729
}
2830

29-
// no supported update method
30-
return nil
31+
panic(fmt.Sprintf("no supported update method in provided type %T", &model))
3132
}
3233

3334
func UpdateTeaModelsInplace[M any](msg tea.Msg, models ...*M) tea.Cmd {

ui/tui/util/viewport.go

Lines changed: 98 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,12 @@ import (
88
"strings"
99

1010
"github.com/charmbracelet/lipgloss"
11+
"github.com/charmbracelet/x/ansi"
1112
)
1213

1314
func RenderContentInViewportSmooth(
1415
content string,
15-
viewportHeight,
16+
viewportHeight int,
1617
targetY int,
1718
targetHeight int,
1819
) string {
@@ -28,24 +29,115 @@ func RenderContentInViewportSmooth(
2829
return strings.Join(lines[offsetY:offsetY+viewportHeight], "\n")
2930
}
3031

31-
func RenderContentInViewportAligned(
32+
func RenderContentInViewportAlign(
3233
content string,
33-
viewportHeight,
34+
viewportSize Size,
35+
targetPos Vec[int],
36+
targetSize Size,
37+
align Vec[lipgloss.Position],
38+
) string {
39+
return RenderContentInViewportAlignX(
40+
RenderContentInViewportAlignY(
41+
content,
42+
viewportSize.Height,
43+
targetPos.Y,
44+
targetSize.Height,
45+
align.Y,
46+
),
47+
viewportSize.Width,
48+
targetPos.X,
49+
targetSize.Width,
50+
align.X,
51+
)
52+
}
53+
54+
func RenderContentInViewportAlignX(
55+
content string,
56+
viewportWidth int,
57+
targetX int,
58+
targetWidth int,
59+
align lipgloss.Position,
60+
) string {
61+
contentWidth := lipgloss.Width(content)
62+
63+
// // enlarge content
64+
// if contentSize.Width <= viewportSize.Width {
65+
// content = lipgloss.PlaceHorizontal(viewportSize.Width, lipgloss.Left, content)
66+
// }
67+
68+
// downsize viewport
69+
viewportWidth = Clamp(targetWidth, viewportWidth, contentWidth)
70+
71+
// early exit when content fits into viewport
72+
if contentWidth <= viewportWidth {
73+
return content
74+
}
75+
76+
// ensure sane values to prevent panics
77+
targetWidth = Clamp(1, targetWidth, viewportWidth)
78+
targetX = Clamp(0, targetX, contentWidth-targetWidth)
79+
align = Clamp(lipgloss.Left, align, lipgloss.Right)
80+
81+
// calculate offsets
82+
offset := Clamp(
83+
0,
84+
targetX-int(math.Round(float64(viewportWidth-targetWidth)*float64(align))),
85+
contentWidth-viewportWidth,
86+
)
87+
88+
// cut X
89+
lines := strings.Split(content, "\n")
90+
for i := range lines {
91+
lines[i] = ansi.TruncateLeft(
92+
ansi.Truncate(
93+
lines[i],
94+
offset+viewportWidth,
95+
"",
96+
),
97+
offset,
98+
"",
99+
)
100+
}
101+
102+
return strings.Join(lines, "\n")
103+
}
104+
105+
func RenderContentInViewportAlignY(
106+
content string,
107+
viewportHeight int,
34108
targetY int,
35109
targetHeight int,
36110
align lipgloss.Position,
37111
) string {
38112
contentHeight := lipgloss.Height(content)
113+
114+
// // enlarge content
115+
// if contentSize.Height <= viewportSize.Height {
116+
// content = lipgloss.PlaceHorizontal(viewportSize.Height, lipgloss.Top, content)
117+
// }
118+
119+
// downsize viewport
120+
viewportHeight = Clamp(targetHeight, viewportHeight, contentHeight)
121+
122+
// early exit when content fits into viewport
39123
if contentHeight <= viewportHeight {
40124
return content
41125
}
42126

43-
offsetY := Clamp(
127+
// ensure sane values to prevent panics
128+
targetHeight = Clamp(1, targetHeight, viewportHeight)
129+
targetY = Clamp(0, targetY, contentHeight-targetHeight)
130+
align = Clamp(lipgloss.Top, align, lipgloss.Bottom)
131+
132+
// calculate offsets
133+
offset := Clamp(
44134
0,
45135
targetY-int(math.Round(float64(viewportHeight-targetHeight)*float64(align))),
46136
contentHeight-viewportHeight,
47137
)
48138

49-
lines := strings.Split(content, "\n")
50-
return strings.Join(lines[offsetY:offsetY+viewportHeight], "\n")
139+
// cut Y
140+
lines := strings.Split(content, "\n")[offset : offset+viewportHeight]
141+
142+
return strings.Join(lines, "\n")
51143
}

ui/tui/util/viewport_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
// Copyright (c) 2026 Keymaster Team
2+
// Keymaster - SSH key management system
3+
// This source code is licensed under the MIT license found in the LICENSE file.
4+
package util
5+
6+
import (
7+
"strings"
8+
"testing"
9+
10+
"github.com/bobg/go-generics/v4/slices"
11+
"github.com/charmbracelet/lipgloss"
12+
)
13+
14+
// Helper to joinLines lines for cleaner test cases
15+
func joinLinesSlc(lines []string) string { return strings.Join(lines, "\n") }
16+
func joinLines(lines ...string) string { return joinLinesSlc(lines) }
17+
func joinSlc(lines []string) string { return strings.Join(lines, "") }
18+
19+
var testRows = [][]string{
20+
{"R1C1", "R1C2", "R1C3", "R1C4", "R1C5"},
21+
{"R2C1", "R2C2", "R2C3", "R2C4", "R2C5"},
22+
{"R3C1", "R3C2", "R3C3", "R3C4", "R3C5"},
23+
{"R4C1", "R4C2", "R4C3", "R4C4", "R4C5"},
24+
{"R5C1", "R5C2", "R5C3", "R5C4", "R5C5"},
25+
}
26+
var testContent = joinLinesSlc(slices.Map(testRows, joinSlc))
27+
28+
func testSubset(from, to Vec[int]) string {
29+
strs := make([]string, 0, to.Y-from.Y+1)
30+
for i := from.Y - 1; i <= to.Y-1; i++ {
31+
strs = append(strs, joinSlc(testRows[i][from.X-1:to.X]))
32+
}
33+
return joinLinesSlc(strs)
34+
}
35+
func newSize(x, y int) Size { return Size{x * 4, y} }
36+
func newPos(x, y int) Vec[int] { return Vec[int]{(x - 1) * 4, y - 1} }
37+
38+
func TestRenderContentInViewportAlignXY(t *testing.T) {
39+
tests := []struct {
40+
name string
41+
vSize Size
42+
tPos Vec[int]
43+
tSize Size
44+
align Vec[lipgloss.Position]
45+
want string
46+
}{
47+
{
48+
name: "1x1 cell",
49+
vSize: newSize(1, 1),
50+
tPos: newPos(2, 2),
51+
tSize: newSize(1, 1),
52+
align: Vec[lipgloss.Position]{lipgloss.Center, lipgloss.Center},
53+
want: testSubset(Vec[int]{2, 2}, Vec[int]{2, 2}),
54+
// want: testRows[1][1],
55+
},
56+
{
57+
name: "inner 3x3 Top Left",
58+
vSize: newSize(3, 3),
59+
tPos: newPos(3, 3),
60+
tSize: newSize(1, 1),
61+
align: Vec[lipgloss.Position]{lipgloss.Top, lipgloss.Left},
62+
want: testSubset(Vec[int]{3, 3}, Vec[int]{5, 5}),
63+
},
64+
{
65+
name: "inner 3x3 Center Bottom",
66+
vSize: newSize(3, 3),
67+
tPos: newPos(3, 3),
68+
tSize: newSize(1, 1),
69+
align: Vec[lipgloss.Position]{lipgloss.Center, lipgloss.Bottom},
70+
want: testSubset(Vec[int]{2, 1}, Vec[int]{4, 3}),
71+
},
72+
{
73+
name: "inner 3x3 Right Center",
74+
vSize: newSize(3, 3),
75+
tPos: newPos(3, 3),
76+
tSize: newSize(1, 1),
77+
align: Vec[lipgloss.Position]{lipgloss.Right, lipgloss.Center},
78+
want: testSubset(Vec[int]{1, 2}, Vec[int]{3, 4}),
79+
},
80+
{
81+
name: "inner 3x3 Center Center",
82+
vSize: newSize(3, 3),
83+
tPos: newPos(3, 3),
84+
tSize: newSize(1, 1),
85+
align: Vec[lipgloss.Position]{lipgloss.Center, lipgloss.Center},
86+
want: testSubset(Vec[int]{2, 2}, Vec[int]{4, 4}),
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
got := RenderContentInViewportAlign(testContent, tt.vSize, tt.tPos, tt.tSize, tt.align)
93+
if got != tt.want {
94+
t.Errorf("got:\n%s\nwant:\n%s", got, tt.want)
95+
}
96+
})
97+
}
98+
}

0 commit comments

Comments
 (0)