Skip to content

Commit ecd8457

Browse files
authored
feat: auto fit monitors to preview pane (#76)
## What does this PR do? Adds `T` tui keybind to `fix monitors to screen`, by default it's run on the first render as well. When editing monitors it centers on the monitor center, rather than left corner. ## Why is this change important? Requested by users, better UX. ## How to test this PR locally? `make pre-push` ## Related issues Closes #74
2 parents 356ae7d + 5b1735f commit ecd8457

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

61 files changed

+844
-586
lines changed

docs/docs/quickstart/tui.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ The Monitors view shows connected monitors on the left and a visual preview on t
7070
|-----|--------|
7171
| `+` | Zoom in on the preview |
7272
| `-` | Zoom out on the preview |
73+
| `T` | Zoom and pan monitors to fit the preview screen |
7374

7475
![Zoom](/previews/zoom.gif)
7576

internal/tui/keymap.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ type keyMap struct {
2626
ExpandHyprPreview key.Binding
2727
ResetZoom key.Binding
2828
EditHyprGeneratedConfig key.Binding
29+
FitMonitors key.Binding
2930
}
3031

3132
var rootKeyMap = keyMap{
@@ -121,4 +122,8 @@ var rootKeyMap = keyMap{
121122
key.WithKeys("E"),
122123
key.WithHelp("E", "edit hypr generated config (ephemeral)"),
123124
),
125+
FitMonitors: key.NewBinding(
126+
key.WithKeys("T"),
127+
key.WithHelp("T", "auto fit monitors preview"),
128+
),
124129
}

internal/tui/monitors_preview_pane.go

Lines changed: 98 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ type MonitorsPreviewPane struct {
3939
}
4040

4141
func NewMonitorsPreviewPane(monitors []*MonitorSpec) *MonitorsPreviewPane {
42-
return &MonitorsPreviewPane{
42+
pane := &MonitorsPreviewPane{
4343
monitors: monitors,
4444
selectedIndex: -1,
4545
panX: 0,
@@ -55,6 +55,99 @@ func NewMonitorsPreviewPane(monitors []*MonitorSpec) *MonitorsPreviewPane {
5555
snapping: true,
5656
zoomStep: 1.1,
5757
}
58+
59+
pane.autoFitMonitors()
60+
61+
return pane
62+
}
63+
64+
// calculateMonitorBounds returns the bounding box of all non-disabled monitors
65+
// Returns (left, right, top, bottom, hasMonitors)
66+
func (p *MonitorsPreviewPane) calculateMonitorBounds() (int, int, int, int, bool) {
67+
var minX, maxX, minY, maxY int
68+
hasMonitors := false
69+
70+
for _, monitor := range p.monitors {
71+
if monitor.Disabled {
72+
continue
73+
}
74+
75+
scaledWidth := int(float64(monitor.Width) / monitor.Scale)
76+
scaledHeight := int(float64(monitor.Height) / monitor.Scale)
77+
visualWidth := scaledWidth
78+
visualHeight := scaledHeight
79+
80+
if monitor.NeedsDimensionsSwap() {
81+
visualWidth = scaledHeight
82+
visualHeight = scaledWidth
83+
}
84+
85+
monitorLeft := monitor.X
86+
monitorRight := monitor.X + visualWidth
87+
monitorTop := monitor.Y
88+
monitorBottom := monitor.Y + visualHeight
89+
90+
if !hasMonitors {
91+
minX = monitorLeft
92+
maxX = monitorRight
93+
minY = monitorTop
94+
maxY = monitorBottom
95+
hasMonitors = true
96+
continue
97+
}
98+
99+
if monitorLeft < minX {
100+
minX = monitorLeft
101+
}
102+
if monitorRight > maxX {
103+
maxX = monitorRight
104+
}
105+
if monitorTop < minY {
106+
minY = monitorTop
107+
}
108+
if monitorBottom > maxY {
109+
maxY = monitorBottom
110+
}
111+
}
112+
113+
return minX, maxX, minY, maxY, hasMonitors
114+
}
115+
116+
// autoFitMonitors centers the view on all monitors and adjusts zoom to fit
117+
func (p *MonitorsPreviewPane) autoFitMonitors() {
118+
minX, maxX, minY, maxY, hasMonitors := p.calculateMonitorBounds()
119+
120+
if !hasMonitors {
121+
return
122+
}
123+
124+
p.panX = (minX + maxX) / 2
125+
p.panY = (minY + maxY) / 2
126+
monitorWidth := maxX - minX
127+
monitorHeight := maxY - minY
128+
// Add padding on both sides
129+
withPadding := 1.6
130+
131+
if monitorWidth > 0 && monitorHeight > 0 {
132+
paddedWidth := int(float64(monitorWidth) * withPadding)
133+
paddedHeight := int(float64(monitorHeight) * withPadding)
134+
aspectRatio := p.AspectRatio()
135+
paddedHeight = int(float64(paddedHeight) * aspectRatio)
136+
137+
// Lock both to the same value
138+
p.virtualWidth = max(paddedWidth, paddedHeight, 1000)
139+
p.virtualHeight = max(paddedWidth, paddedHeight, 1000)
140+
141+
p.originalVirtualWidth = p.virtualWidth
142+
p.originalVirtualHeight = p.virtualHeight
143+
}
144+
}
145+
146+
func (p *MonitorsPreviewPane) panToMonitorCenter() {
147+
monitor := p.monitors[p.selectedIndex]
148+
x, y := monitor.Center()
149+
p.panX = x
150+
p.panY = y
58151
}
59152

60153
func (p *MonitorsPreviewPane) Update(msg tea.Msg) tea.Cmd {
@@ -68,16 +161,11 @@ func (p *MonitorsPreviewPane) Update(msg tea.Msg) tea.Cmd {
68161
p.snapGridX = nil
69162
p.snapGridY = nil
70163
if p.followMonitor {
71-
monitor := p.monitors[p.selectedIndex]
72-
p.panX = monitor.X
73-
p.panY = monitor.Y
164+
p.panToMonitorCenter()
74165
}
75166
case MonitorBeingEdited:
76167
p.selectedIndex = msg.ListIndex
77-
// set panning to the current monitor left top corner
78-
monitor := p.monitors[p.selectedIndex]
79-
p.panX = monitor.X
80-
p.panY = monitor.Y
168+
p.panToMonitorCenter()
81169
case MonitorUnselected:
82170
p.selectedIndex = -1
83171
case StateChanged:
@@ -116,6 +204,8 @@ func (p *MonitorsPreviewPane) Update(msg tea.Msg) tea.Cmd {
116204
p.ZoomOut()
117205
case key.Matches(msg, rootKeyMap.ResetZoom):
118206
p.ResetZoom()
207+
case key.Matches(msg, rootKeyMap.FitMonitors):
208+
p.autoFitMonitors()
119209
}
120210
}
121211

internal/tui/monitors_preview_pane_test.go

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -126,70 +126,70 @@ func TestMonitorsPreviewPane_KeyboardControls(t *testing.T) {
126126
name: "up key moves pan up when panning",
127127
setupCmd: tui.StateChangedCmd(tui.AppState{Panning: true}),
128128
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}},
129-
expectedPanX: 0,
130-
expectedPanY: -100,
131-
expectedVirtualW: 8000,
132-
expectedVirtualH: 8000,
129+
expectedPanX: 960,
130+
expectedPanY: 440,
131+
expectedVirtualW: 3456,
132+
expectedVirtualH: 3456,
133133
},
134134
{
135135
name: "down key moves pan down when panning",
136136
setupCmd: tui.StateChangedCmd(tui.AppState{Panning: true}),
137137
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}},
138-
expectedPanX: 0,
139-
expectedPanY: 100,
140-
expectedVirtualW: 8000,
141-
expectedVirtualH: 8000,
138+
expectedPanX: 960,
139+
expectedPanY: 640,
140+
expectedVirtualW: 3456,
141+
expectedVirtualH: 3456,
142142
},
143143
{
144144
name: "left key moves pan left when panning",
145145
setupCmd: tui.StateChangedCmd(tui.AppState{Panning: true}),
146146
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'h'}},
147-
expectedPanX: -100,
148-
expectedPanY: 0,
149-
expectedVirtualW: 8000,
150-
expectedVirtualH: 8000,
147+
expectedPanX: 860,
148+
expectedPanY: 540,
149+
expectedVirtualW: 3456,
150+
expectedVirtualH: 3456,
151151
},
152152
{
153153
name: "right key moves pan right when panning",
154154
setupCmd: tui.StateChangedCmd(tui.AppState{Panning: true}),
155155
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'l'}},
156-
expectedPanX: 100,
157-
expectedPanY: 0,
158-
expectedVirtualW: 8000,
159-
expectedVirtualH: 8000,
156+
expectedPanX: 1060,
157+
expectedPanY: 540,
158+
expectedVirtualW: 3456,
159+
expectedVirtualH: 3456,
160160
},
161161
{
162162
name: "up key does nothing when not panning",
163163
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'k'}},
164-
expectedPanX: 0,
165-
expectedPanY: 0,
166-
expectedVirtualW: 8000,
167-
expectedVirtualH: 8000,
164+
expectedPanX: 960,
165+
expectedPanY: 540,
166+
expectedVirtualW: 3456,
167+
expectedVirtualH: 3456,
168168
},
169169
{
170170
name: "center key resets pan to 0,0",
171171
setupCmd: tui.StateChangedCmd(tui.AppState{Panning: true}),
172172
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}},
173173
expectedPanX: 0,
174174
expectedPanY: 0,
175-
expectedVirtualW: 8000,
176-
expectedVirtualH: 8000,
175+
expectedVirtualW: 3456,
176+
expectedVirtualH: 3456,
177177
},
178178
{
179179
name: "zoom in decreases virtual dimensions",
180180
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}},
181-
expectedPanX: 0,
182-
expectedPanY: 0,
183-
expectedVirtualW: 7272,
184-
expectedVirtualH: 7272,
181+
expectedPanX: 960,
182+
expectedPanY: 540,
183+
expectedVirtualW: 3141,
184+
expectedVirtualH: 3141,
185185
},
186186
{
187187
name: "zoom out increases virtual dimensions",
188188
key: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}},
189-
expectedPanX: 0,
190-
expectedPanY: 0,
191-
expectedVirtualW: 8800,
192-
expectedVirtualH: 8800,
189+
expectedPanX: 960,
190+
expectedPanY: 540,
191+
expectedVirtualW: 3801,
192+
expectedVirtualH: 3801,
193193
},
194194
}
195195

internal/tui/root.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -552,7 +552,7 @@ func (m *Model) GlobalHelp() []key.Binding {
552552
rootKeyMap.Pan,
553553
rootKeyMap.Fullscreen, rootKeyMap.FollowMonitor, rootKeyMap.Center, rootKeyMap.ZoomIn, rootKeyMap.ZoomOut,
554554
rootKeyMap.ToggleSnapping, rootKeyMap.ApplyHypr,
555-
rootKeyMap.ExpandHyprPreview, rootKeyMap.ResetZoom,
555+
rootKeyMap.ExpandHyprPreview, rootKeyMap.ResetZoom, rootKeyMap.FitMonitors,
556556
}
557557
bindings = append(bindings, monitors...)
558558

internal/tui/root_test.go

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,17 @@ func TestModel_Update_UserFlows(t *testing.T) {
5454
monitorsData: defaultMonitorData,
5555
runFor: utils.JustPtr(500 * time.Millisecond),
5656
steps: []step{
57+
{
58+
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}},
59+
expectOutputToContain: "► DP-1",
60+
},
5761
{
5862
msg: tea.KeyMsg{Type: tea.KeyEnter},
5963
expectOutputToContain: "EDITING",
6064
},
6165
{
6266
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}},
63-
expectOutputToContain: "eDP-1→",
67+
expectOutputToContain: "DP-1→",
6468
},
6569
},
6670
},
@@ -414,7 +418,7 @@ func TestModel_Update_UserFlows(t *testing.T) {
414418
{
415419
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}},
416420
times: utils.IntPtr(10),
417-
expectOutputToContain: "Center: (3800,1000)",
421+
expectOutputToContain: "Center: (7640,2020)",
418422
},
419423
},
420424
},
@@ -504,19 +508,51 @@ func TestModel_Update_UserFlows(t *testing.T) {
504508
steps: []step{
505509
{
506510
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'+'}},
507-
expectOutputToContain: "Virtual Area: 7272x7272 ",
511+
expectOutputToContain: "Virtual Area: 11170x11170",
508512
},
509513
},
510514
},
511515

516+
{
517+
name: "initial",
518+
monitorsData: defaultMonitorData,
519+
runFor: utils.JustPtr(500 * time.Millisecond),
520+
steps: []step{},
521+
},
522+
512523
{
513524
name: "zoom_out",
514525
monitorsData: defaultMonitorData,
515526
runFor: utils.JustPtr(500 * time.Millisecond),
516527
steps: []step{
517528
{
518529
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'-'}},
519-
expectOutputToContain: "Virtual Area: 8800x8800",
530+
expectOutputToContain: "Virtual Area: 13516x13516",
531+
},
532+
},
533+
},
534+
535+
{
536+
name: "fit",
537+
monitorsData: defaultMonitorData,
538+
runFor: utils.JustPtr(800 * time.Millisecond),
539+
steps: []step{
540+
{
541+
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}},
542+
expectOutputToContain: "► HEADLESS-1 (Headless Virtua...)",
543+
times: utils.JustPtr(3),
544+
},
545+
{
546+
msg: tea.KeyMsg{Type: tea.KeyEnter},
547+
expectOutputToContain: "► HEADLESS-1 (Headless Virtua...) [EDITING]",
548+
},
549+
{
550+
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'e'}},
551+
expectOutputToContain: "Disabled",
552+
},
553+
{
554+
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'T'}},
555+
expectOutputToContain: "Center: (2880,1020)",
520556
},
521557
},
522558
},
@@ -537,11 +573,11 @@ func TestModel_Update_UserFlows(t *testing.T) {
537573
{
538574
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'j'}},
539575
times: utils.IntPtr(10),
540-
expectOutputToContain: "Center: (1000,1000)",
576+
expectOutputToContain: "Center: (4840,2020)",
541577
},
542578
{
543579
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}},
544-
expectOutputToContain: "Virtual Area: 8000x8000 | Snapping",
580+
expectOutputToContain: "Virtual Area: 12288x12288 | Snapping",
545581
},
546582
{
547583
msg: tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'p'}},

0 commit comments

Comments
 (0)