add "inputs" page

Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 17:59:03 +02:00
parent df530f39b0
commit 6e4f8b889c
23 changed files with 1047 additions and 2 deletions
+4 -1
View File
@@ -13,6 +13,7 @@ import (
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
inputUI "github.com/anotherhadi/settuings/internal/ui/input"
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
"github.com/sirupsen/logrus"
@@ -45,6 +46,7 @@ type Model struct {
bluetooth bluetoothUI.Model
audio audioUI.Model
power powerUI.Model
input inputUI.Model
notifications notificationsUI.Model
}
@@ -74,6 +76,7 @@ func New(initialPage string) (Model, error) {
bluetooth: bluetoothUI.New(),
audio: audioUI.New(),
power: powerUI.New(),
input: inputUI.New(),
notifications: notificationsUI.New(),
sidebarState: sidebarState(cfg.TUI.DefaultSidebarState),
logPath: logPath,
@@ -92,7 +95,7 @@ func New(initialPage string) (Model, error) {
func (m Model) FatalErr() error { return m.fatalErr }
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init(), m.power.Init()}
cmds := []tea.Cmd{m.about.Init(), m.network.Init(), m.bluetooth.Init(), m.audio.Init(), m.power.Init(), m.input.Init()}
if m.page != pageAbout {
cmds = append(cmds, m.activatePage(m.page))
}
+16
View File
@@ -9,6 +9,7 @@ import (
aboutUI "github.com/anotherhadi/settuings/internal/ui/about"
audioUI "github.com/anotherhadi/settuings/internal/ui/audio"
bluetoothUI "github.com/anotherhadi/settuings/internal/ui/bluetooth"
inputUI "github.com/anotherhadi/settuings/internal/ui/input"
networkUI "github.com/anotherhadi/settuings/internal/ui/network"
powerUI "github.com/anotherhadi/settuings/internal/ui/power"
)
@@ -21,6 +22,7 @@ const (
pageBluetooth page = "Bluetooth"
pageAudio page = "Audio"
pagePower page = "Power"
pageInputs page = "Inputs"
)
// pageEntry describes a page and all its integration hooks.
@@ -120,6 +122,20 @@ var pageRegistry = []pageEntry{
resize: func(m *Model, w, h int) { m.power.SetSize(w, h) },
activate: func(m *Model) tea.Cmd { return m.power.Activate() },
},
{
id: pageInputs,
icon: func() string { return icons.I.Inputs },
render: func(m *Model) string { return m.input.View().Content },
update: func(m *Model, msg tea.Msg) tea.Cmd {
updated, cmd := m.input.Update(msg)
m.input = updated.(inputUI.Model)
return cmd
},
isEditing: func(m *Model) bool { return m.input.IsEditing() },
resize: func(m *Model, w, h int) { m.input.SetSize(w, h) },
activate: func(m *Model) tea.Cmd { return m.input.Activate() },
},
}
// PageNames returns the valid page identifiers, in sidebar order, usable
+44
View File
@@ -0,0 +1,44 @@
package input
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/input"
)
const tickInterval = 5 * time.Second
func tickCmd() tea.Cmd {
return tea.Tick(tickInterval, func(time.Time) tea.Msg { return tickMsg{} })
}
func checkCmd() tea.Cmd {
return func() tea.Msg { return checkMsg{err: input.CheckAvailable()} }
}
func fetchLayoutCmd() tea.Cmd {
return func() tea.Msg {
layout, err := input.CurrentLayout()
return layoutMsg{layout: layout, err: err}
}
}
func setLayoutCmd(code string) tea.Cmd {
return func() tea.Msg { return setLayoutMsg{err: input.SetLayout(code)} }
}
func fetchSensitivityCmd() tea.Cmd {
return func() tea.Msg {
value, err := input.CurrentSensitivity()
return sensitivityMsg{value: value, err: err}
}
}
func setSensitivityCmd(v float64) tea.Cmd {
return func() tea.Msg { return setSensitivityMsg{err: input.SetSensitivity(v)} }
}
func resetCmd() tea.Cmd {
return func() tea.Msg { return resetMsg{err: input.Reset()} }
}
+134
View File
@@ -0,0 +1,134 @@
package input
import (
"fmt"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/icons"
inputpkg "github.com/anotherhadi/settuings/internal/input"
"github.com/anotherhadi/settuings/internal/keys"
)
func (m *Model) handleLayout(msg layoutMsg) {
m.layoutErr = msg.err
if msg.err != nil {
return
}
m.currentLayout = msg.layout
if !m.cursorInit {
m.cursorInit = true
for i, l := range inputpkg.Layouts {
if l.Code == msg.layout {
m.cursor = i
break
}
}
}
}
func (m *Model) handleSetLayout(msg setLayoutMsg) tea.Cmd {
m.layoutPending = false
if msg.err != nil {
m.layoutFeedback = msg.err.Error()
return nil
}
m.layoutFeedback = ""
return fetchLayoutCmd()
}
func (m Model) selectedLayout() *inputpkg.Layout {
if m.cursor < 0 || m.cursor >= len(inputpkg.Layouts) {
return nil
}
return &inputpkg.Layouts[m.cursor]
}
func (m *Model) updateLayout(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
in := keys.Keys.Input
switch {
case key.Matches(keyMsg, g.Up):
if m.cursor > 0 {
m.cursor--
}
case key.Matches(keyMsg, g.Down):
if m.cursor < len(inputpkg.Layouts)-1 {
m.cursor++
}
case key.Matches(keyMsg, in.Select):
return m.applySelectedLayout()
}
return nil
}
func (m *Model) applySelectedLayout() tea.Cmd {
sel := m.selectedLayout()
if sel == nil || sel.Code == m.currentLayout {
return nil
}
m.layoutPending = true
m.layoutFeedback = ""
return setLayoutCmd(sel.Code)
}
func (m Model) renderLayout(width, height int) string {
title := sectionTitleStyle(m.focus == focusLayout).Render("Keyboard Layout")
switch {
case m.layoutErr != nil:
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.layoutErr.Error()))
case m.currentLayout == "" && !m.cursorInit:
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading current layout…"))
}
current := subtleStyle().Render("Current: ") + inputpkg.LayoutName(m.currentLayout) + subtleStyle().Render(" ("+m.currentLayout+")")
lines := []string{title, "", current, ""}
available := height - len(lines) - 2
if available < 1 {
available = 1
}
p, start, end := paginate(m.cursor, len(inputpkg.Layouts), available)
for i := start; i < end; i++ {
lines = append(lines, m.renderLayoutRow(inputpkg.Layouts[i], i == m.cursor))
}
footer := renderPaginatorDots(p)
switch {
case m.layoutPending:
footer = mutedStyle().Render("applying…")
case m.layoutFeedback != "":
footer = errorStyle().Render(m.layoutFeedback)
}
lines = append(lines, "", footer)
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
func (m Model) renderLayoutRow(l inputpkg.Layout, selected bool) string {
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
if selected {
textStyle = textStyle.Bold(true)
if m.focus == focusLayout {
textStyle = textStyle.Foreground(ilovetui.S.Primary)
}
}
cursor := " "
if selected {
cursor = "> "
}
check := " "
if l.Code == m.currentLayout {
check = successStyle().Render(icons.I.Check)
}
return fmt.Sprintf("%s%s%s", cursor, check, textStyle.Render(l.Name+" ("+l.Code+")"))
}
+52
View File
@@ -0,0 +1,52 @@
package input
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/settuings/internal/keys"
)
// inputKeyMap renders the bottom help bar, adapting to which section
// currently has focus.
type inputKeyMap struct {
m Model
width int
}
// relabel reuses an existing binding's keys under a different help
// description, same trick the audio page uses for its volume keys.
func relabel(b key.Binding, desc string) key.Binding {
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc))
}
func (k inputKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
in := keys.Keys.Input
if k.m.checkErr != nil {
return []key.Binding{g.Help}
}
switch k.m.focus {
case focusMouse:
speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
return []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus, g.Help}
default:
return []key.Binding{g.Up, g.Down, in.Select, in.Reset, g.CycleFocus, g.Help}
}
}
func (k inputKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
in := keys.Keys.Input
var bindings []key.Binding
switch {
case k.m.checkErr != nil:
bindings = nil
case k.m.focus == focusMouse:
speedDown, speedUp := relabel(g.Left, "speed down"), relabel(g.Right, "speed up")
bindings = []key.Binding{speedDown, speedUp, in.Reset, g.CycleFocus}
default:
bindings = []key.Binding{g.Up, g.Down, in.Select, in.Reset, g.CycleFocus}
}
bindings = append(bindings, g.CommonBindings()...)
return keys.ChunkByWidth(bindings, k.width)
}
+27
View File
@@ -0,0 +1,27 @@
package input
// checkMsg carries the result of input.CheckAvailable (hyprctl).
type checkMsg struct{ err error }
// tickMsg triggers a periodic refresh of layout + sensitivity while this
// page is active, so it reflects layout/sensitivity changes made elsewhere
// (hyprland.conf reload, another tool).
type tickMsg struct{}
type layoutMsg struct {
layout string
err error
}
type setLayoutMsg struct{ err error }
type sensitivityMsg struct {
value float64
err error
}
type setSensitivityMsg struct{ err error }
// resetMsg carries the result of resetting both the keyboard layout and
// mouse sensitivity to their hyprland.conf values.
type resetMsg struct{ err error }
+67
View File
@@ -0,0 +1,67 @@
// Package input provides the "Inputs" TUI page: keyboard layout and mouse
// sensitivity, controlled via hyprctl, in the same spirit as the power
// page's controller-power + status split.
package input
import (
"charm.land/bubbles/v2/help"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
// focusZone tracks which of the two sections (keyboard layout list, mouse
// sensitivity slider) currently receives Up/Down/Left/Right/Select.
type focusZone int
const (
focusLayout focusZone = iota
focusMouse
)
type Model struct {
width int
height int
help help.Model
focus focusZone
checkErr error // reason hyprctl is unusable; nil once confirmed OK
checked bool
layoutErr error
currentLayout string
cursor int
cursorInit bool // whether cursor has been snapped to the current layout yet
layoutPending bool
layoutFeedback string
sensitivityErr error
sensitivity float64
sensitivityKnown bool
mousePending bool
mouseFeedback string
}
func New() Model {
return Model{help: ilovetui.NewHelp()}
}
func (m Model) Init() tea.Cmd {
return nil
}
// Activate is called every time the user navigates onto this page. It
// re-checks hyprctl availability and fetches the current layout + mouse
// sensitivity, then starts the periodic auto-refresh loop.
func (m *Model) Activate() tea.Cmd {
return tea.Batch(checkCmd(), fetchLayoutCmd(), fetchSensitivityCmd(), tickCmd())
}
func (m Model) IsEditing() bool {
return false
}
func (m *Model) SetSize(w, h int) {
m.width = w
m.height = h
m.help.SetWidth(w - 2)
}
+118
View File
@@ -0,0 +1,118 @@
package input
import (
"fmt"
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
inputpkg "github.com/anotherhadi/settuings/internal/input"
"github.com/anotherhadi/settuings/internal/keys"
)
const sensitivityStep = 0.05
func (m *Model) handleSensitivity(msg sensitivityMsg) {
m.sensitivityErr = msg.err
if msg.err != nil {
return
}
m.sensitivity = msg.value
m.sensitivityKnown = true
}
func (m *Model) handleSetSensitivity(msg setSensitivityMsg) tea.Cmd {
m.mousePending = false
if msg.err != nil {
m.mouseFeedback = msg.err.Error()
return nil
}
m.mouseFeedback = ""
return fetchSensitivityCmd()
}
func (m *Model) updateMouse(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
switch {
case key.Matches(keyMsg, g.Left):
return m.adjustSensitivity(-sensitivityStep)
case key.Matches(keyMsg, g.Right):
return m.adjustSensitivity(sensitivityStep)
}
return nil
}
// adjustSensitivity nudges the mouse sensitivity by delta, updating it
// locally right away so the bar responds instantly instead of waiting on
// the round trip to hyprctl and back, mirroring the audio page's volume
// adjustment.
func (m *Model) adjustSensitivity(delta float64) tea.Cmd {
if !m.sensitivityKnown {
return nil
}
v := m.sensitivity + delta
switch {
case v < inputpkg.MinSensitivity:
v = inputpkg.MinSensitivity
case v > inputpkg.MaxSensitivity:
v = inputpkg.MaxSensitivity
}
m.sensitivity = v
m.mouseFeedback = ""
m.mousePending = true
return setSensitivityCmd(v)
}
func (m Model) renderMouse(width int) string {
title := sectionTitleStyle(m.focus == focusMouse).Render("Mouse Speed")
switch {
case m.sensitivityErr != nil:
return lipgloss.JoinVertical(lipgloss.Left, title, "", errorStyle().Render(m.sensitivityErr.Error()))
case !m.sensitivityKnown:
return lipgloss.JoinVertical(lipgloss.Left, title, "", ilovetui.S.Faint.Render("Loading mouse sensitivity…"))
}
lines := []string{title, "", renderSensitivityBar(m.sensitivity, width)}
switch {
case m.mousePending:
lines = append(lines, "", mutedStyle().Render("applying…"))
case m.mouseFeedback != "":
lines = append(lines, "", errorStyle().Render(m.mouseFeedback))
}
return lipgloss.JoinVertical(lipgloss.Left, lines...)
}
// renderSensitivityBar draws a static block-bar gauge for a sensitivity in
// [MinSensitivity, MaxSensitivity], in the same spirit as the audio page's
// volume bar and the power page's battery gauge.
func renderSensitivityBar(value float64, width int) string {
barWidth := width / 2
switch {
case barWidth < 10:
barWidth = 10
case barWidth > 30:
barWidth = 30
}
span := inputpkg.MaxSensitivity - inputpkg.MinSensitivity
filled := int((value-inputpkg.MinSensitivity)/span*float64(barWidth) + 0.5)
switch {
case filled > barWidth:
filled = barWidth
case filled < 0:
filled = 0
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
gauge := lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("[" + bar + "]")
return fmt.Sprintf("%s %+.2f", gauge, value)
}
+44
View File
@@ -0,0 +1,44 @@
package input
import (
"charm.land/bubbles/v2/paginator"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
func newPaginator() paginator.Model {
p := paginator.New()
p.Type = paginator.Dots
p.ActiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Primary).Render("•")
p.InactiveDot = lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Render("•")
return p
}
// paginate slices a list of length total into pages of availableRows items,
// deriving the current page from cursor (the globally-indexed selection)
// rather than tracking page state separately. It returns the configured
// paginator and the [start, end) bounds of the visible slice.
func paginate(cursor, total, availableRows int) (p paginator.Model, start, end int) {
p = newPaginator()
perPage := availableRows
if perPage < 1 {
perPage = 1
}
p.PerPage = perPage
p.SetTotalPages(total)
if p.TotalPages > 0 {
p.Page = cursor / perPage
if p.Page >= p.TotalPages {
p.Page = p.TotalPages - 1
}
}
start, end = p.GetSliceBounds(total)
return p, start, end
}
func renderPaginatorDots(p paginator.Model) string {
if p.TotalPages <= 1 {
return ""
}
return mutedStyle().Render(p.View())
}
+26
View File
@@ -0,0 +1,26 @@
package input
import tea "charm.land/bubbletea/v2"
// applyReset kicks off a `hyprctl reload`, which restores the keyboard
// layout and mouse sensitivity to their hyprland.conf values (along with
// anything else set there); both sections are marked pending since a
// reload touches them together.
func (m *Model) applyReset() tea.Cmd {
m.layoutPending = true
m.mousePending = true
m.layoutFeedback = ""
m.mouseFeedback = ""
return resetCmd()
}
func (m *Model) handleReset(msg resetMsg) tea.Cmd {
m.layoutPending = false
m.mousePending = false
if msg.err != nil {
m.layoutFeedback = msg.err.Error()
m.mouseFeedback = msg.err.Error()
return nil
}
return tea.Batch(fetchLayoutCmd(), fetchSensitivityCmd())
}
+33
View File
@@ -0,0 +1,33 @@
package input
import (
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
func errorStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ilovetui.S.Error)
}
func successStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ilovetui.S.Success)
}
func subtleStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ilovetui.S.Subtle)
}
func mutedStyle() lipgloss.Style {
return lipgloss.NewStyle().Foreground(ilovetui.S.Muted)
}
// sectionTitleStyle highlights a section's title when it holds keyboard
// focus, so Tab-cycling between the layout list and the mouse slider is
// visible even before the user starts navigating within a section.
func sectionTitleStyle(focused bool) lipgloss.Style {
style := lipgloss.NewStyle().Bold(true)
if focused {
style = style.Foreground(ilovetui.S.Primary)
}
return style
}
+29
View File
@@ -0,0 +1,29 @@
package input
import (
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/icons"
)
// renderTabBar draws a header matching the power page's tab bar: a single
// always-active label, since this page has no tabs of its own.
func renderTabBar(width int) string {
style := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(ilovetui.S.Primary)
label := style.Render(icons.I.Inputs + "Inputs")
filler := width - lipgloss.Width(label)
if filler > 0 {
label = lipgloss.JoinHorizontal(lipgloss.Bottom, label,
lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(ilovetui.S.Primary).
Render(strings.Repeat(" ", filler)))
}
return label
}
+80
View File
@@ -0,0 +1,80 @@
package input
import (
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/keys"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case checkMsg:
wasUnavailable := m.checkErr != nil
m.checked = true
m.checkErr = msg.err
if msg.err == nil && wasUnavailable {
// recovered: resume fetching
return m, tea.Batch(fetchLayoutCmd(), fetchSensitivityCmd())
}
return m, nil
case tickMsg:
if m.checkErr != nil {
// Not manually retriable anymore (no refresh key): keep
// re-checking on the tick so the page self-heals once
// hyprctl/Hyprland becomes available again.
return m, tea.Batch(tickCmd(), checkCmd())
}
return m, tea.Batch(tickCmd(), fetchLayoutCmd(), fetchSensitivityCmd())
case layoutMsg:
m.handleLayout(msg)
return m, nil
case setLayoutMsg:
return m, m.handleSetLayout(msg)
case sensitivityMsg:
m.handleSensitivity(msg)
return m, nil
case setSensitivityMsg:
return m, m.handleSetSensitivity(msg)
case resetMsg:
return m, m.handleReset(msg)
case tea.KeyPressMsg:
if key.Matches(msg, keys.Keys.Global.Help) {
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
if m.checkErr != nil {
return m, nil
}
if key.Matches(msg, keys.Keys.Global.CycleFocus) {
m.cycleFocus()
return m, nil
}
if key.Matches(msg, keys.Keys.Input.Reset) {
return m, m.applyReset()
}
switch m.focus {
case focusLayout:
return m, m.updateLayout(msg)
case focusMouse:
return m, m.updateMouse(msg)
}
}
return m, nil
}
func (m *Model) cycleFocus() {
if m.focus == focusLayout {
m.focus = focusMouse
} else {
m.focus = focusLayout
}
}
+103
View File
@@ -0,0 +1,103 @@
package input
import (
"errors"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/input"
)
func windowStyle() lipgloss.Style {
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(ilovetui.S.Subtle).
Padding(1, 2)
}
func (m Model) View() tea.View {
statusBar := m.renderStatusBar()
innerW, innerH := m.contentBudget()
tabBar := renderTabBar(innerW)
var body string
switch {
case !m.checked:
body = ilovetui.S.Faint.Render("Checking hyprctl…")
case m.checkErr != nil:
body = m.renderUnavailable()
default:
body = m.renderBody(innerW)
}
box := lipgloss.NewStyle().Width(innerW).Height(innerH).Render(body)
content := windowStyle().Render(lipgloss.JoinVertical(lipgloss.Left, tabBar, box))
return tea.NewView(lipgloss.JoinVertical(lipgloss.Left, content, statusBar))
}
func (m Model) renderStatusBar() string {
km := inputKeyMap{m: m, width: m.width}
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
}
// contentBudget returns the exact space available inside the bordered page
// box, below the tab bar and above the status bar.
func (m Model) contentBudget() (w, h int) {
frameW := windowStyle().GetHorizontalFrameSize()
frameH := windowStyle().GetVerticalFrameSize()
w = m.width - frameW
statusH := strings.Count(m.renderStatusBar(), "\n") + 1
tabBarH := lipgloss.Height(renderTabBar(w))
h = m.height - tabBarH - frameH - statusH
if h < 1 {
h = 1
}
return w, h
}
// renderBody lays out the two sections side by side once there's enough
// width for both to breathe, and stacks them otherwise.
func (m Model) renderBody(width int) string {
const splitThreshold = 60
const gap = 3
_, height := m.contentBudget()
if width < splitThreshold {
return lipgloss.JoinVertical(lipgloss.Left, m.renderLayout(width, height/2), "", m.renderMouse(width))
}
leftW := width * 3 / 5
if leftW < 30 {
leftW = 30
}
rightW := width - leftW - gap
left := lipgloss.NewStyle().Width(leftW).Render(m.renderLayout(leftW, height))
right := lipgloss.NewStyle().Width(rightW).Render(m.renderMouse(rightW))
return lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), right)
}
func (m Model) renderUnavailable() string {
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Inputs page unavailable")
var hint string
switch {
case errors.Is(m.checkErr, input.ErrNotFound):
hint = "hyprctl was not found in PATH. This page only works under Hyprland."
default:
hint = m.checkErr.Error()
}
return lipgloss.JoinVertical(lipgloss.Left,
title,
"",
subtleStyle().Render(hint),
"",
mutedStyle().Render("Retrying automatically…"),
)
}