From 6e4f8b889cab156591524d2d49b67ef5afcd8965 Mon Sep 17 00:00:00 2001 From: Hadi <112569860+anotherhadi@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:59:03 +0200 Subject: [PATCH] add "inputs" page Signed-off-by: Hadi <112569860+anotherhadi@users.noreply.github.com> --- README.md | 3 +- internal/config/default_config.yaml | 4 + internal/config/keybindings.go | 6 ++ internal/icons/icons.go | 2 + internal/input/hyprctl.go | 53 ++++++++++ internal/input/keyboard.go | 145 ++++++++++++++++++++++++++++ internal/input/mouse.go | 34 +++++++ internal/keys/input.go | 22 +++++ internal/keys/keys.go | 2 + internal/ui/app/model.go | 5 +- internal/ui/app/pages.go | 16 +++ internal/ui/input/commands.go | 44 +++++++++ internal/ui/input/keyboard.go | 134 +++++++++++++++++++++++++ internal/ui/input/keymap.go | 52 ++++++++++ internal/ui/input/messages.go | 27 ++++++ internal/ui/input/model.go | 67 +++++++++++++ internal/ui/input/mouse.go | 118 ++++++++++++++++++++++ internal/ui/input/pagination.go | 44 +++++++++ internal/ui/input/reset.go | 26 +++++ internal/ui/input/style.go | 33 +++++++ internal/ui/input/tabbar.go | 29 ++++++ internal/ui/input/update.go | 80 +++++++++++++++ internal/ui/input/view.go | 103 ++++++++++++++++++++ 23 files changed, 1047 insertions(+), 2 deletions(-) create mode 100644 internal/input/hyprctl.go create mode 100644 internal/input/keyboard.go create mode 100644 internal/input/mouse.go create mode 100644 internal/keys/input.go create mode 100644 internal/ui/input/commands.go create mode 100644 internal/ui/input/keyboard.go create mode 100644 internal/ui/input/keymap.go create mode 100644 internal/ui/input/messages.go create mode 100644 internal/ui/input/model.go create mode 100644 internal/ui/input/mouse.go create mode 100644 internal/ui/input/pagination.go create mode 100644 internal/ui/input/reset.go create mode 100644 internal/ui/input/style.go create mode 100644 internal/ui/input/tabbar.go create mode 100644 internal/ui/input/update.go create mode 100644 internal/ui/input/view.go diff --git a/README.md b/README.md index ae242ad..159f330 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ SetTUIngs is a keyboard-driven TUI for the everyday Linux system settings you'd - **Bluetooth**: Discover, pair, connect, trust, and forget devices, and power the adapter on/off. - **Audio**: Adjust output/input volume, mute, and pick the default speaker or mic; preview a device with a test tone or a live input-level meter; control the volume of individual apps (browser tab, Spotify, etc.) independently. - **Power**: Switch power profiles (performance, balanced, power-saver) and check battery charge, health, and time remaining at a glance. +- **Inputs**: Change the keyboard layout and adjust mouse pointer speed, via `hyprctl` (Hyprland only). - **Vim-like Navigation**: The entire interface is keyboard-driven with Vim-inspired shortcuts. Use `h/j/k/l` to move, `gg`/`G` to jump to the top/bottom, `/` to search, `q` to close panels, and more. All keybindings are fully customizable via the config file. ## Installation @@ -91,7 +92,7 @@ Flags: --add-default-config copy the default config file to the config path and exit -c, --config string path to config file -h, --help help for settuings - -p, --page string page to open at launch (About, Network, Bluetooth, Audio, Power) + -p, --page string page to open at launch (About, Network, Bluetooth, Audio, Power, Inputs) -v, --version version for settuings ``` diff --git a/internal/config/default_config.yaml b/internal/config/default_config.yaml index 55edf0d..5ca3fcb 100644 --- a/internal/config/default_config.yaml +++ b/internal/config/default_config.yaml @@ -59,3 +59,7 @@ keybindings: power: select: "enter" refresh: "r" + + input: + select: "enter" + reset: "r" diff --git a/internal/config/keybindings.go b/internal/config/keybindings.go index 989d2fc..093b4c1 100644 --- a/internal/config/keybindings.go +++ b/internal/config/keybindings.go @@ -59,6 +59,11 @@ type PowerKeys struct { Refresh string `mapstructure:"refresh"` } +type InputKeys struct { + Select string `mapstructure:"select"` + Reset string `mapstructure:"reset"` +} + type Keybindings struct { Global GlobalKeys `mapstructure:"global"` Docs DocsKeys `mapstructure:"docs"` @@ -66,4 +71,5 @@ type Keybindings struct { Bluetooth BluetoothKeys `mapstructure:"bluetooth"` Audio AudioKeys `mapstructure:"audio"` Power PowerKeys `mapstructure:"power"` + Input InputKeys `mapstructure:"input"` } diff --git a/internal/icons/icons.go b/internal/icons/icons.go index ab8dd81..8788dad 100644 --- a/internal/icons/icons.go +++ b/internal/icons/icons.go @@ -12,6 +12,7 @@ type Icons struct { Bluetooth string Audio string Power string + Inputs string Wifi string Ethernet string @@ -39,6 +40,7 @@ func Init(cfg *config.Config) { Bluetooth: "󰂯 ", Audio: "󰓃 ", Power: "󰐥 ", + Inputs: "󰌌 ", Wifi: "󰤨 ", Ethernet: "󰈀 ", diff --git a/internal/input/hyprctl.go b/internal/input/hyprctl.go new file mode 100644 index 0000000..8dcda26 --- /dev/null +++ b/internal/input/hyprctl.go @@ -0,0 +1,53 @@ +package input + +import ( + "bytes" + "context" + "errors" + "fmt" + "os/exec" + "strings" + "time" +) + +var ErrNotFound = errors.New("hyprctl not found in PATH") + +const defaultTimeout = 15 * time.Second + +func CheckAvailable() error { + if _, err := exec.LookPath("hyprctl"); err != nil { + return ErrNotFound + } + _, err := run("version") + return err +} + +func Reset() error { + _, err := run("reload") + return err +} + +func run(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "hyprctl", args...) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + if errors.Is(err, exec.ErrNotFound) { + return "", ErrNotFound + } + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = strings.TrimSpace(stdout.String()) + } + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("hyprctl %s: %s", strings.Join(args, " "), msg) + } + return stdout.String(), nil +} diff --git a/internal/input/keyboard.go b/internal/input/keyboard.go new file mode 100644 index 0000000..dc074e3 --- /dev/null +++ b/internal/input/keyboard.go @@ -0,0 +1,145 @@ +package input + +import ( + "encoding/json" + "fmt" + "strings" +) + +type Layout struct { + Code string + Name string +} + +var Layouts = []Layout{ + {"al", "Albanian"}, + {"et", "Amharic"}, + {"am", "Armenian"}, + {"ara", "Arabic"}, + {"eg", "Arabic (Egypt)"}, + {"iq", "Arabic (Iraq)"}, + {"ma", "Arabic (Morocco)"}, + {"sy", "Arabic (Syria)"}, + {"az", "Azerbaijani"}, + {"ml", "Bambara"}, + {"bd", "Bangla"}, + {"by", "Belarusian"}, + {"be", "Belgian"}, + {"dz", "Berber (Algeria, Latin)"}, + {"ba", "Bosnian"}, + {"brai", "Braille"}, + {"bg", "Bulgarian"}, + {"mm", "Burmese"}, + {"cn", "Chinese"}, + {"hr", "Croatian"}, + {"cz", "Czech"}, + {"dk", "Danish"}, + {"af", "Dari"}, + {"mv", "Dhivehi"}, + {"nl", "Dutch"}, + {"bt", "Dzongkha"}, + {"au", "English (Australia)"}, + {"cm", "English (Cameroon)"}, + {"gh", "English (Ghana)"}, + {"nz", "English (New Zealand)"}, + {"ng", "English (Nigeria)"}, + {"za", "English (South Africa)"}, + {"gb", "English (UK)"}, + {"us", "English (US)"}, + {"epo", "Esperanto"}, + {"ee", "Estonian"}, + {"fo", "Faroese"}, + {"ph", "Filipino"}, + {"fi", "Finnish"}, + {"fr", "French"}, + {"ca", "French (Canada)"}, + {"cd", "French (Democratic Republic of the Congo)"}, + {"tg", "French (Togo)"}, + {"ge", "Georgian"}, + {"de", "German"}, + {"at", "German (Austria)"}, + {"ch", "German (Switzerland)"}, + {"gr", "Greek"}, + {"il", "Hebrew"}, + {"hu", "Hungarian"}, + {"is", "Icelandic"}, + {"in", "Indian"}, + {"id", "Indonesian (Latin)"}, + {"ie", "Irish"}, + {"it", "Italian"}, + {"jp", "Japanese"}, + {"kz", "Kazakh"}, + {"kh", "Khmer (Cambodia)"}, + {"kr", "Korean"}, + {"kg", "Kyrgyz"}, + {"la", "Lao"}, + {"lv", "Latvian"}, + {"lt", "Lithuanian"}, + {"mk", "Macedonian"}, + {"my", "Malay (Jawi, Arabic Keyboard)"}, + {"mt", "Maltese"}, + {"md", "Moldavian"}, + {"mn", "Mongolian"}, + {"me", "Montenegrin"}, + {"np", "Nepali"}, + {"gn", "N'Ko (AZERTY)"}, + {"no", "Norwegian"}, + {"ir", "Persian"}, + {"pl", "Polish"}, + {"pt", "Portuguese"}, + {"br", "Portuguese (Brazil)"}, + {"ro", "Romanian"}, + {"ru", "Russian"}, + {"rs", "Serbian"}, + {"lk", "Sinhala (phonetic)"}, + {"sk", "Slovak"}, + {"si", "Slovenian"}, + {"es", "Spanish"}, + {"latam", "Spanish (Latin American)"}, + {"ke", "Swahili (Kenya)"}, + {"tz", "Swahili (Tanzania)"}, + {"se", "Swedish"}, + {"tw", "Taiwanese"}, + {"tj", "Tajik"}, + {"th", "Thai"}, + {"bw", "Tswana"}, + {"tm", "Turkmen"}, + {"tr", "Turkish"}, + {"ua", "Ukrainian"}, + {"pk", "Urdu (Pakistan)"}, + {"uz", "Uzbek"}, + {"vn", "Vietnamese"}, + {"sn", "Wolof"}, +} + +func LayoutName(code string) string { + for _, l := range Layouts { + if l.Code == code { + return l.Name + } + } + return code +} + +// getOptionResp is the shape of `hyprctl getoption -j`. +type getOptionResp struct { + Str string `json:"str"` + Float float64 `json:"float"` +} + +func CurrentLayout() (string, error) { + out, err := run("getoption", "input:kb_layout", "-j") + if err != nil { + return "", err + } + var resp getOptionResp + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return "", fmt.Errorf("parse getoption input:kb_layout: %w", err) + } + return strings.TrimSpace(resp.Str), nil +} + +func SetLayout(code string) error { + _, err := run("keyword", "input:kb_layout", code) + return err +} diff --git a/internal/input/mouse.go b/internal/input/mouse.go new file mode 100644 index 0000000..8c5a7bb --- /dev/null +++ b/internal/input/mouse.go @@ -0,0 +1,34 @@ +package input + +import ( + "encoding/json" + "fmt" +) + +const ( + MinSensitivity = -1.0 + MaxSensitivity = 1.0 +) + +func CurrentSensitivity() (float64, error) { + out, err := run("getoption", "input:sensitivity", "-j") + if err != nil { + return 0, err + } + var resp getOptionResp + if err := json.Unmarshal([]byte(out), &resp); err != nil { + return 0, fmt.Errorf("parse getoption input:sensitivity: %w", err) + } + return resp.Float, nil +} + +func SetSensitivity(v float64) error { + switch { + case v < MinSensitivity: + v = MinSensitivity + case v > MaxSensitivity: + v = MaxSensitivity + } + _, err := run("keyword", "input:sensitivity", fmt.Sprintf("%.2f", v)) + return err +} diff --git a/internal/keys/input.go b/internal/keys/input.go new file mode 100644 index 0000000..4bda9a1 --- /dev/null +++ b/internal/keys/input.go @@ -0,0 +1,22 @@ +package keys + +import ( + "charm.land/bubbles/v2/key" + "github.com/anotherhadi/settuings/internal/config" +) + +type InputKeyMap struct { + Select key.Binding + Reset key.Binding +} + +func newInputKeyMap(cfg config.InputKeys) InputKeyMap { + return InputKeyMap{ + Select: binding(cfg.Select, "apply layout"), + Reset: binding(cfg.Reset, "reset to config"), + } +} + +func (i InputKeyMap) Bindings() []key.Binding { + return []key.Binding{i.Select, i.Reset} +} diff --git a/internal/keys/keys.go b/internal/keys/keys.go index 78343ea..2248724 100644 --- a/internal/keys/keys.go +++ b/internal/keys/keys.go @@ -14,6 +14,7 @@ type KeyMap struct { Bluetooth BluetoothKeyMap Audio AudioKeyMap Power PowerKeyMap + Input InputKeyMap } var Keys *KeyMap @@ -27,6 +28,7 @@ func Init(cfg *config.Config) { Bluetooth: newBluetoothKeyMap(kb.Bluetooth), Audio: newAudioKeyMap(kb.Audio), Power: newPowerKeyMap(kb.Power), + Input: newInputKeyMap(kb.Input), } } diff --git a/internal/ui/app/model.go b/internal/ui/app/model.go index 7b09930..31099de 100644 --- a/internal/ui/app/model.go +++ b/internal/ui/app/model.go @@ -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)) } diff --git a/internal/ui/app/pages.go b/internal/ui/app/pages.go index 8c9db34..c4f429f 100644 --- a/internal/ui/app/pages.go +++ b/internal/ui/app/pages.go @@ -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 diff --git a/internal/ui/input/commands.go b/internal/ui/input/commands.go new file mode 100644 index 0000000..edb3d92 --- /dev/null +++ b/internal/ui/input/commands.go @@ -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()} } +} diff --git a/internal/ui/input/keyboard.go b/internal/ui/input/keyboard.go new file mode 100644 index 0000000..2fefaf4 --- /dev/null +++ b/internal/ui/input/keyboard.go @@ -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+")")) +} diff --git a/internal/ui/input/keymap.go b/internal/ui/input/keymap.go new file mode 100644 index 0000000..4ee3659 --- /dev/null +++ b/internal/ui/input/keymap.go @@ -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) +} diff --git a/internal/ui/input/messages.go b/internal/ui/input/messages.go new file mode 100644 index 0000000..8b1893a --- /dev/null +++ b/internal/ui/input/messages.go @@ -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 } diff --git a/internal/ui/input/model.go b/internal/ui/input/model.go new file mode 100644 index 0000000..b949d62 --- /dev/null +++ b/internal/ui/input/model.go @@ -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) +} diff --git a/internal/ui/input/mouse.go b/internal/ui/input/mouse.go new file mode 100644 index 0000000..9081bdf --- /dev/null +++ b/internal/ui/input/mouse.go @@ -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) +} diff --git a/internal/ui/input/pagination.go b/internal/ui/input/pagination.go new file mode 100644 index 0000000..5f4f13a --- /dev/null +++ b/internal/ui/input/pagination.go @@ -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()) +} diff --git a/internal/ui/input/reset.go b/internal/ui/input/reset.go new file mode 100644 index 0000000..f0cf05b --- /dev/null +++ b/internal/ui/input/reset.go @@ -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()) +} diff --git a/internal/ui/input/style.go b/internal/ui/input/style.go new file mode 100644 index 0000000..1244e27 --- /dev/null +++ b/internal/ui/input/style.go @@ -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 +} diff --git a/internal/ui/input/tabbar.go b/internal/ui/input/tabbar.go new file mode 100644 index 0000000..b0f16dd --- /dev/null +++ b/internal/ui/input/tabbar.go @@ -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 +} diff --git a/internal/ui/input/update.go b/internal/ui/input/update.go new file mode 100644 index 0000000..3ae262b --- /dev/null +++ b/internal/ui/input/update.go @@ -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 + } +} diff --git a/internal/ui/input/view.go b/internal/ui/input/view.go new file mode 100644 index 0000000..bec232d --- /dev/null +++ b/internal/ui/input/view.go @@ -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…"), + ) +}