Skip to content

Commit 7496b56

Browse files
authored
feat(tui): show hypr rendered configuration in the profile view (#55)
## What does this PR do? Shows hypr rendered configuration in the profiles view. ## Why is this change important? Easier to look for issues with the configuration and experiment ad-hoc. ## How to test this PR locally? `make pre-push`
2 parents 35a7adc + a5e43b3 commit 7496b56

Some content is hidden

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

56 files changed

+659
-272
lines changed

Makefile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,7 @@ record/previews: build/docs
276276
$(MAKE) record/preview RECORD_TARGET=create_profile
277277
$(MAKE) record/preview RECORD_TARGET=edit_existing
278278
$(MAKE) record/preview RECORD_TARGET=color
279+
$(MAKE) record/preview RECORD_TARGET=render_edit
279280

280281
docs:
281282
@cd ./docs && npm run start

README.md

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,28 @@ An event-driven service with an interactive TUI that automatically manages Hyprl
1818

1919
## Preview
2020

21-
### Full TUI demo
22-
2321
![TUI demo](./preview/output/demo.gif)
2422

25-
### Daemon Render on async power events
23+
24+
<details>
25+
<summary>Daemon Response to Power State Events</summary>
2626

2727
![power events demo](./preview/output/power_events.gif)
2828

29+
</details>
30+
31+
<details>
32+
<summary>Daemon and TUI Response to Laptop Lid Events</summary>
33+
34+
![lid tui events demo](./preview/output/lid_tui.gif)
35+
36+
</details>
37+
2938
## Table Of Contents
3039

3140
<!--ts-->
3241
* [HyprDynamicMonitors](#hyprdynamicmonitors)
3342
* [Preview](#preview)
34-
* [Full TUI demo](#full-tui-demo)
35-
* [Daemon Render on async power events](#daemon-render-on-async-power-events)
3643
* [Table Of Contents](#table-of-contents)
3744
* [Features](#features)
3845
* [Quick Start](#quick-start)

docs/docs/intro.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,14 @@ It also provides a standalone TUI that can be used for ad-hoc modifications and
1414

1515
![Demo](/previews/demo.gif)
1616

17+
:::note
18+
For keybinds explanation and usage see [TUI](quickstart/tui.md).
19+
:::
20+
1721
### Async Events
1822

23+
In this preview, the `HyprDynamicMonitors` daemon runs in the background and automatically updates the Hyprland configuration when a lid close event is detected (via D-Bus). The TUI detects the configuration change and updates to show the new matching profile.
24+
1925
![Events](/previews/lid_tui.gif)
2026

2127
## Features

docs/docs/quickstart/tui.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,23 @@ The TUI shows different status messages:
254254

255255
![Opening config file](/previews/hdm_c.gif)
256256

257+
### Rendering Hyprland Configuration
258+
259+
The TUI can generate and edit the Hyprland monitor configuration on-demand:
260+
261+
| Key | Action |
262+
|-----|--------|
263+
| `R` | Render configuration from template/static file to the destination |
264+
| `E` | Edit the rendered configuration file in your `$EDITOR` |
265+
266+
The `R` command writes the rendered output to your configured `config.general.destination`. You can then manually edit this file with `E`.
267+
268+
:::caution Ephemeral Changes
269+
If the `hyprdynamicmonitors` daemon is running, any manual edits to the rendered configuration will be overwritten when the daemon responds to monitor or power state events.
270+
:::
271+
272+
![Rendering and editing config](/previews/render_edit.gif)
273+
257274
---
258275

259276
## Tips

internal/tui/hdm_config_pane.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,16 +16,18 @@ import (
1616
)
1717

1818
type hdmKeyMap struct {
19-
NewProfile key.Binding
20-
ApplyProfile key.Binding
21-
EditorEdit key.Binding
19+
NewProfile key.Binding
20+
ApplyProfile key.Binding
21+
EditorEdit key.Binding
22+
RenderProfile key.Binding
2223
}
2324

2425
func (h *hdmKeyMap) Help() []key.Binding {
2526
return []key.Binding{
2627
h.NewProfile,
2728
h.ApplyProfile,
2829
h.EditorEdit,
30+
h.RenderProfile,
2931
}
3032
}
3133

@@ -54,6 +56,10 @@ func NewHDMConfigPane(cfg *config.Config, matcher *matchers.Matcher, monitors []
5456
lidState: lidState,
5557
help: help.New(),
5658
keymap: &hdmKeyMap{
59+
RenderProfile: key.NewBinding(
60+
key.WithKeys("R"),
61+
key.WithHelp("R", "render profile to config.general.destination"),
62+
),
5763
NewProfile: key.NewBinding(
5864
key.WithKeys("n"),
5965
key.WithHelp("n", "new profile"),
@@ -117,6 +123,8 @@ func (h *HDMConfigPane) Update(msg tea.Msg) tea.Cmd {
117123
cmds = append(cmds, editProfileConfirmationCmd(h.profile.Name))
118124
case key.Matches(msg, h.keymap.EditorEdit):
119125
cmds = append(cmds, openEditor(h.profile.ConfigFile))
126+
case key.Matches(msg, h.keymap.RenderProfile):
127+
cmds = append(cmds, RenderHDMConfigCmd(h.profile, h.lidState, h.powerState))
120128
}
121129
}
122130
return tea.Batch(cmds...)

internal/tui/hdm_profile_preview.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ func (h *HDMProfilePreview) View() string {
124124
configFile = filepath.Base(configFile)
125125
}
126126

127-
subtitle := SubtitleInfoStyle.Margin(0, 0, 1, 0).Render(configFile)
127+
subtitle := SubtitleInfoStyle.Margin(0, 0, 1, 0).Width(h.width).Render(configFile)
128128
availableHeight -= lipgloss.Height(subtitle)
129129
sections = append(sections, subtitle)
130130

internal/tui/hypr_apply.go

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,27 @@
11
package tui
22

33
import (
4+
"errors"
45
"fmt"
56
"os/exec"
67

78
tea "github.com/charmbracelet/bubbletea"
9+
"github.com/fiffeek/hyprdynamicmonitors/internal/config"
10+
"github.com/fiffeek/hyprdynamicmonitors/internal/generators"
11+
"github.com/fiffeek/hyprdynamicmonitors/internal/power"
812
"github.com/fiffeek/hyprdynamicmonitors/internal/profilemaker"
913
"github.com/sirupsen/logrus"
1014
)
1115

1216
type HyprApply struct {
1317
profileMaker *profilemaker.Service
18+
generator *generators.ConfigGenerator
1419
}
1520

16-
func NewHyprApply(profileMaker *profilemaker.Service) *HyprApply {
21+
func NewHyprApply(profileMaker *profilemaker.Service, generator *generators.ConfigGenerator) *HyprApply {
1722
return &HyprApply{
1823
profileMaker: profileMaker,
24+
generator: generator,
1925
}
2026
}
2127

@@ -50,3 +56,18 @@ func (h *HyprApply) EditProfile(monitors []*MonitorSpec, name string) tea.Cmd {
5056
err = h.profileMaker.EditExisting(name, hyprMonitors)
5157
return OperationStatusCmd(OperationNameEditProfile, err)
5258
}
59+
60+
func (h *HyprApply) GenerateThroughHDM(cfg *config.Config, profile *config.Profile,
61+
monitors []*MonitorSpec, powerState power.PowerState, lidState power.LidState,
62+
) tea.Cmd {
63+
if profile == nil {
64+
return OperationStatusCmd(OperationNameHydrate, errors.New("profile is nil"))
65+
}
66+
hyprMonitors, err := ConvertToHyprMonitors(monitors)
67+
if err != nil {
68+
return OperationStatusCmd(OperationNameHydrate, err)
69+
}
70+
destination := *cfg.Get().General.Destination
71+
_, err = h.generator.GenerateConfig(cfg.Get(), profile, hyprMonitors, powerState, lidState, destination)
72+
return OperationStatusCmd(OperationNameHydrate, err)
73+
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package tui
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"time"
7+
8+
"github.com/charmbracelet/bubbles/textarea"
9+
tea "github.com/charmbracelet/bubbletea"
10+
"github.com/charmbracelet/lipgloss"
11+
"github.com/fiffeek/hyprdynamicmonitors/internal/config"
12+
"github.com/sirupsen/logrus"
13+
)
14+
15+
type HDMGeneratedConfigPreview struct {
16+
cfg *config.Config
17+
textarea textarea.Model
18+
height int
19+
width int
20+
runningUnderTest bool
21+
reloaded bool
22+
destinationExists bool
23+
}
24+
25+
func NewHDMGeneratedConfigPreview(cfg *config.Config,
26+
runningUnderTest bool,
27+
) *HDMGeneratedConfigPreview {
28+
ta := textarea.New()
29+
return &HDMGeneratedConfigPreview{
30+
cfg: cfg,
31+
textarea: ta,
32+
runningUnderTest: runningUnderTest,
33+
reloaded: false,
34+
}
35+
}
36+
37+
func (h *HDMGeneratedConfigPreview) SetHeight(height int) {
38+
h.height = height
39+
h.textarea.SetHeight(height)
40+
}
41+
42+
func (h *HDMGeneratedConfigPreview) SetWidth(width int) {
43+
h.width = width
44+
h.textarea.SetWidth(width)
45+
}
46+
47+
func (h *HDMGeneratedConfigPreview) Clear() {
48+
h.reloaded = false
49+
h.destinationExists = false
50+
}
51+
52+
func (h *HDMGeneratedConfigPreview) Update(msg tea.Msg) tea.Cmd {
53+
cmds := []tea.Cmd{}
54+
55+
switch msg := msg.(type) {
56+
case ConfigReloaded:
57+
logrus.Debug("Received config reloaded event")
58+
h.Clear()
59+
60+
case OperationStatus:
61+
if msg.name == OperationNameHydrate || msg.name == OperationNameMatchingProfile {
62+
h.Clear()
63+
}
64+
// poll for the destination changes since the TUI does not watch for these
65+
case reloadHyprConfigMsg:
66+
logrus.Debug("Reload hypr config requested")
67+
h.Clear()
68+
}
69+
70+
if !h.reloaded {
71+
h.reloaded = true
72+
73+
contents, err := os.ReadFile(*h.cfg.Get().General.Destination)
74+
text := ""
75+
if err == nil {
76+
h.destinationExists = true
77+
text = string(contents)
78+
}
79+
80+
if err == nil && text != h.textarea.Value() {
81+
cmds = append(cmds, OperationStatusCmd(OperationNameReloadHyprDestination, nil))
82+
}
83+
84+
logrus.Debugf("Textarea text: %s", text)
85+
h.textarea.SetValue(text)
86+
87+
logrus.Debug("Requesting reloading hypr config")
88+
cmds = append(cmds, reloadHyprConfig(5*time.Second))
89+
}
90+
91+
return tea.Batch(cmds...)
92+
}
93+
94+
func (h *HDMGeneratedConfigPreview) View() string {
95+
sections := []string{}
96+
availableHeight := h.height
97+
98+
title := TitleStyle.Margin(0, 0, 0, 0).Render("Generated hypr config preview")
99+
availableHeight -= lipgloss.Height(title)
100+
sections = append(sections, title)
101+
102+
configFile := *h.cfg.Get().General.Destination
103+
if h.runningUnderTest {
104+
configFile = filepath.Base(configFile)
105+
}
106+
107+
subtitle := SubtitleInfoStyle.Margin(0, 0, 1, 0).Render(configFile)
108+
availableHeight -= lipgloss.Height(subtitle)
109+
sections = append(sections, subtitle)
110+
111+
if h.destinationExists {
112+
h.textarea.SetWidth(h.width)
113+
h.textarea.SetHeight(availableHeight)
114+
ta := h.textarea.View()
115+
sections = append(sections, ta)
116+
} else {
117+
warnSections := []string{}
118+
warnSections = append(warnSections, "Are you running the daemon?")
119+
warnSections = append(warnSections, lipgloss.NewStyle().Width(h.width).Margin(0, 0,
120+
1, 0).Render("See: "+LinkStyle.Render("https://hyprdynamicmonitors.filipmikina.com/docs/quickstart/setup-approaches")))
121+
warnSections = append(warnSections, SubtitleInfoStyle.Width(h.width).Render(
122+
"Alternatively, run the generation once: `hyprdynamicmonitors run --run-once`"))
123+
sections = append(sections, lipgloss.JoinVertical(lipgloss.Top, warnSections...))
124+
}
125+
126+
return lipgloss.JoinVertical(lipgloss.Top, sections...)
127+
}

internal/tui/keymap.go

Lines changed: 27 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,29 @@ package tui
33
import "github.com/charmbracelet/bubbles/key"
44

55
type keyMap struct {
6-
Tab key.Binding
7-
Enter key.Binding
8-
Quit key.Binding
9-
Up key.Binding
10-
Center key.Binding
11-
Down key.Binding
12-
Left key.Binding
13-
Right key.Binding
14-
ShowFullHelp key.Binding
15-
CloseFullHelp key.Binding
16-
Pan key.Binding
17-
Fullscreen key.Binding
18-
ZoomIn key.Binding
19-
ZoomOut key.Binding
20-
NextPage key.Binding
21-
ToggleSnapping key.Binding
22-
ApplyHypr key.Binding
23-
Back key.Binding
24-
EditHDMConfig key.Binding
25-
FollowMonitor key.Binding
26-
ExpandHyprPreview key.Binding
27-
ResetZoom key.Binding
6+
Tab key.Binding
7+
Enter key.Binding
8+
Quit key.Binding
9+
Up key.Binding
10+
Center key.Binding
11+
Down key.Binding
12+
Left key.Binding
13+
Right key.Binding
14+
ShowFullHelp key.Binding
15+
CloseFullHelp key.Binding
16+
Pan key.Binding
17+
Fullscreen key.Binding
18+
ZoomIn key.Binding
19+
ZoomOut key.Binding
20+
NextPage key.Binding
21+
ToggleSnapping key.Binding
22+
ApplyHypr key.Binding
23+
Back key.Binding
24+
EditHDMConfig key.Binding
25+
FollowMonitor key.Binding
26+
ExpandHyprPreview key.Binding
27+
ResetZoom key.Binding
28+
EditHyprGeneratedConfig key.Binding
2829
}
2930

3031
var rootKeyMap = keyMap{
@@ -116,4 +117,8 @@ var rootKeyMap = keyMap{
116117
key.WithKeys("R"),
117118
key.WithHelp("R", "reset zoom"),
118119
),
120+
EditHyprGeneratedConfig: key.NewBinding(
121+
key.WithKeys("E"),
122+
key.WithHelp("E", "edit hypr generated config (ephemeral)"),
123+
),
119124
}

internal/tui/layout.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,14 @@ func (l *Layout) RightHyprHeight() int {
7575
return l.AvailableHeight() - l.RightPreviewHeight()
7676
}
7777

78+
func (l *Layout) RightPanelTemplatePreviewHeight() int {
79+
return 1 * l.AvailableHeight() / 2
80+
}
81+
82+
func (l *Layout) RightPanelGeneratedPreviewHeight() int {
83+
return l.AvailableHeight() - l.RightPanelTemplatePreviewHeight()
84+
}
85+
7886
func (l *Layout) PromptWidth() int {
7987
return l.AvailableWidth() / 3
8088
}

0 commit comments

Comments
 (0)