mirror of
https://github.com/anotherhadi/settuings.git
synced 2026-10-05 08:18:23 +02:00
@@ -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
|
||||
```
|
||||
<!-- endexec -->
|
||||
|
||||
@@ -59,3 +59,7 @@ keybindings:
|
||||
power:
|
||||
select: "enter"
|
||||
refresh: "r"
|
||||
|
||||
input:
|
||||
select: "enter"
|
||||
reset: "r"
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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: " ",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <name> -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()} }
|
||||
}
|
||||
@@ -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+")"))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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…"),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user