Signed-off-by: Hadi <[email protected]>
This commit is contained in:
Hadi
2026-08-13 00:58:37 +02:00
commit 59b31e5ca3
103 changed files with 8577 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
package audio
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/audio"
"github.com/anotherhadi/settuings/internal/keys"
)
type appsState struct {
loading bool
err error
streams []audio.Stream
cursor int
pending bool
feedback string
}
func newAppsState() appsState {
return appsState{loading: true}
}
func (m *Model) handleAppsList(msg appsListMsg) {
m.apps.loading = false
m.apps.err = msg.err
if msg.err != nil {
return
}
m.apps.streams = msg.streams
if m.apps.cursor >= len(m.apps.streams) {
m.apps.cursor = max(0, len(m.apps.streams)-1)
}
}
func (m *Model) handleAppsAction(msg appsActionMsg) tea.Cmd {
m.apps.pending = false
if msg.err != nil {
m.apps.feedback = msg.err.Error()
return nil
}
return fetchAppsCmd()
}
func (m Model) selectedStream() *audio.Stream {
if m.apps.cursor < 0 || m.apps.cursor >= len(m.apps.streams) {
return nil
}
return &m.apps.streams[m.apps.cursor]
}
func (m *Model) updateApps(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
a := keys.Keys.Audio
switch {
case key.Matches(keyMsg, g.Up):
if m.apps.cursor > 0 {
m.apps.cursor--
}
case key.Matches(keyMsg, g.Down):
if m.apps.cursor < len(m.apps.streams)-1 {
m.apps.cursor++
}
case key.Matches(keyMsg, g.Left):
return m.adjustStreamVolume(-volumeStep)
case key.Matches(keyMsg, g.Right):
return m.adjustStreamVolume(volumeStep)
case key.Matches(keyMsg, a.Refresh):
m.apps.pending = true
return fetchAppsCmd()
case key.Matches(keyMsg, a.Mute):
s := m.selectedStream()
if s == nil {
return nil
}
m.apps.feedback = ""
m.apps.pending = true
return appsToggleMuteCmd(s.ID)
}
return nil
}
func (m *Model) adjustStreamVolume(delta int) tea.Cmd {
s := m.selectedStream()
if s == nil {
return nil
}
pct := s.Volume + delta
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
s.Volume = pct
m.apps.feedback = ""
return appsSetVolumeCmd(s.ID, pct)
}
func sectionTitle(k audio.StreamKind) string {
if k == audio.StreamInput {
return "INPUT STREAMS (recording)"
}
return "OUTPUT STREAMS (playback)"
}
// windowAroundCursor picks a [start, end) slice of size items (or fewer, if
// total is smaller) that keeps cursor in view.
func windowAroundCursor(cursor, total, size int) (start, end int) {
if size >= total {
return 0, total
}
start = cursor - size/2
if start < 0 {
start = 0
}
if start+size > total {
start = total - size
}
return start, start + size
}
func (m Model) renderApps(width, height int) string {
if m.apps.loading {
return ilovetui.S.Faint.Render("Loading application streams…")
}
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("APPLICATIONS")
if m.apps.pending {
header += mutedStyle().Render(" refreshing…")
}
if m.apps.err != nil {
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.apps.err.Error()))
}
if len(m.apps.streams) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No application is currently playing or recording audio."))
}
maxItems := (height - 3) / streamRowHeight
if maxItems < 1 {
maxItems = 1
}
start, end := windowAroundCursor(m.apps.cursor, len(m.apps.streams), maxItems)
sectionStyle := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle)
var b strings.Builder
lastKind := audio.StreamKind(-1)
for i := start; i < end; i++ {
s := m.apps.streams[i]
if s.Kind != lastKind {
b.WriteString(sectionStyle.Render(sectionTitle(s.Kind)) + "\n\n")
lastKind = s.Kind
}
b.WriteString(renderStreamRow(s, i == m.apps.cursor, width))
b.WriteString("\n\n")
}
list := strings.TrimRight(b.String(), "\n")
footer := ""
if m.apps.feedback != "" {
footer = subtleStyle().Render(m.apps.feedback)
}
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
}
+96
View File
@@ -0,0 +1,96 @@
package audio
import (
"time"
tea "charm.land/bubbletea/v2"
"github.com/anotherhadi/settuings/internal/audio"
notificationsUI "github.com/anotherhadi/settuings/internal/ui/components/notifications"
)
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: audio.CheckAvailable()} }
}
func fetchOutputCmd() tea.Cmd {
return func() tea.Msg {
devices, err := audio.ListSinks()
return outputListMsg{devices: devices, err: err}
}
}
func fetchInputCmd() tea.Cmd {
return func() tea.Msg {
devices, err := audio.ListSources()
return inputListMsg{devices: devices, err: err}
}
}
func fetchAppsCmd() tea.Cmd {
return func() tea.Msg {
streams, err := audio.ListStreams()
return appsListMsg{streams: streams, err: err}
}
}
func outputSetVolumeCmd(id string, pct int) tea.Cmd {
return func() tea.Msg { return outputActionMsg{err: audio.SetVolume(id, pct)} }
}
func outputToggleMuteCmd(id string) tea.Cmd {
return func() tea.Msg { return outputActionMsg{err: audio.ToggleMute(id)} }
}
func outputSetDefaultCmd(id string) tea.Cmd {
return func() tea.Msg { return outputActionMsg{err: audio.SetDefault(id)} }
}
func outputTestToneCmd(sinkID string) tea.Cmd {
return func() tea.Msg { return outputActionMsg{err: audio.PlayTestTone(sinkID)} }
}
func inputSetVolumeCmd(id string, pct int) tea.Cmd {
return func() tea.Msg { return inputActionMsg{err: audio.SetVolume(id, pct)} }
}
func inputToggleMuteCmd(id string) tea.Cmd {
return func() tea.Msg { return inputActionMsg{err: audio.ToggleMute(id)} }
}
func inputSetDefaultCmd(id string) tea.Cmd {
return func() tea.Msg { return inputActionMsg{err: audio.SetDefault(id)} }
}
func appsSetVolumeCmd(id string, pct int) tea.Cmd {
return func() tea.Msg { return appsActionMsg{err: audio.SetVolume(id, pct)} }
}
func appsToggleMuteCmd(id string) tea.Cmd {
return func() tea.Msg { return appsActionMsg{err: audio.ToggleMute(id)} }
}
func notifyCmd(title, body string) tea.Cmd {
return func() tea.Msg {
return notificationsUI.NotificationMsg{Title: title, Body: body, Kind: notificationsUI.KindInfo}
}
}
func startLevelMonitorCmd(sourceID string) tea.Cmd {
return func() tea.Msg {
mon, err := audio.StartLevelMonitor(sourceID)
return levelStartedMsg{monitor: mon, err: err}
}
}
func waitLevelCmd(mon *audio.LevelMonitor) tea.Cmd {
return func() tea.Msg {
peak, err := mon.Next()
return levelMsg{peak: peak, err: err}
}
}
+220
View File
@@ -0,0 +1,220 @@
package audio
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/audio"
"github.com/anotherhadi/settuings/internal/keys"
)
type inputState struct {
loading bool
err error
devices []audio.Device
cursor int
pending bool
feedback string
testing bool
monitor *audio.LevelMonitor
level float64 // last peak reading in [0, 1], only meaningful while testing
}
func newInputState() inputState {
return inputState{loading: true}
}
// stopLevelMonitor tears down a running live input-level test, if any. Safe
// to call unconditionally.
func (s *inputState) stopLevelMonitor() {
if s.monitor != nil {
s.monitor.Stop()
s.monitor = nil
}
s.testing = false
s.level = 0
}
func (m *Model) handleInputList(msg inputListMsg) {
m.input.loading = false
m.input.err = msg.err
if msg.err != nil {
return
}
m.input.devices = msg.devices
if m.input.cursor >= len(m.input.devices) {
m.input.cursor = max(0, len(m.input.devices)-1)
}
}
func (m *Model) handleInputAction(msg inputActionMsg) tea.Cmd {
m.input.pending = false
if msg.err != nil {
m.input.feedback = msg.err.Error()
return nil
}
return fetchInputCmd()
}
// handleLevelStarted reacts to a StartLevelMonitor result. If the user
// toggled testing off again before this arrived, the freshly-started monitor
// is stopped immediately instead of being adopted.
func (m *Model) handleLevelStarted(msg levelStartedMsg) tea.Cmd {
if msg.err != nil {
m.input.testing = false
m.input.feedback = msg.err.Error()
return nil
}
if !m.input.testing {
msg.monitor.Stop()
return nil
}
m.input.monitor = msg.monitor
return waitLevelCmd(msg.monitor)
}
// handleLevel reacts to one peak reading, re-arming the wait for the next
// one as long as the test is still active.
func (m *Model) handleLevel(msg levelMsg) tea.Cmd {
if !m.input.testing || m.input.monitor == nil {
return nil
}
if msg.err != nil {
m.input.feedback = "Input test stopped: " + msg.err.Error()
m.input.stopLevelMonitor()
return nil
}
m.input.level = msg.peak
return waitLevelCmd(m.input.monitor)
}
func (m Model) selectedInput() *audio.Device {
if m.input.cursor < 0 || m.input.cursor >= len(m.input.devices) {
return nil
}
return &m.input.devices[m.input.cursor]
}
func (m *Model) updateInput(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
a := keys.Keys.Audio
switch {
case key.Matches(keyMsg, g.Up):
if m.input.cursor > 0 {
m.input.stopLevelMonitor()
m.input.cursor--
}
case key.Matches(keyMsg, g.Down):
if m.input.cursor < len(m.input.devices)-1 {
m.input.stopLevelMonitor()
m.input.cursor++
}
case key.Matches(keyMsg, g.Left):
return m.adjustInputVolume(-volumeStep)
case key.Matches(keyMsg, g.Right):
return m.adjustInputVolume(volumeStep)
case key.Matches(keyMsg, a.Refresh):
m.input.pending = true
return fetchInputCmd()
case key.Matches(keyMsg, a.Mute):
dev := m.selectedInput()
if dev == nil {
return nil
}
m.input.feedback = ""
m.input.pending = true
return inputToggleMuteCmd(dev.ID)
case key.Matches(keyMsg, a.Select):
dev := m.selectedInput()
if dev == nil {
return nil
}
if dev.Default {
m.input.feedback = "Already the default input."
return nil
}
m.input.feedback = ""
m.input.pending = true
return inputSetDefaultCmd(dev.ID)
case key.Matches(keyMsg, a.Test):
dev := m.selectedInput()
if dev == nil {
return nil
}
if m.input.testing {
m.input.stopLevelMonitor()
return nil
}
m.input.testing = true
m.input.feedback = ""
return startLevelMonitorCmd(dev.ID)
}
return nil
}
func (m *Model) adjustInputVolume(delta int) tea.Cmd {
dev := m.selectedInput()
if dev == nil {
return nil
}
pct := dev.Volume + delta
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
dev.Volume = pct
m.input.feedback = ""
return inputSetVolumeCmd(dev.ID, pct)
}
func (m Model) renderInput(width, height int) string {
if m.input.loading {
return ilovetui.S.Faint.Render("Loading input devices…")
}
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("INPUT DEVICES")
if m.input.pending {
header += mutedStyle().Render(" refreshing…")
}
if m.input.err != nil {
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.input.err.Error()))
}
if len(m.input.devices) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No input devices found."))
}
available := (height - 3) / deviceRowHeight
if available < 1 {
available = 1
}
p, start, end := paginate(m.input.cursor, len(m.input.devices), available)
var rows []string
for i := start; i < end; i++ {
selected := i == m.input.cursor
row := renderDeviceRow(m.input.devices[i], selected, width)
if selected && m.input.testing {
row += "\n " + renderLevelMeter(m.input.level, barWidth(width))
}
rows = append(rows, row)
}
list := strings.Join(rows, "\n\n")
footer := renderPaginatorDots(p)
if m.input.feedback != "" {
footer = subtleStyle().Render(m.input.feedback)
}
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
}
+51
View File
@@ -0,0 +1,51 @@
package audio
import (
"charm.land/bubbles/v2/key"
"github.com/anotherhadi/settuings/internal/keys"
)
// audioKeyMap renders the bottom help bar, adapting to the active tab.
type audioKeyMap struct {
m Model
width int
}
// relabel reuses an existing binding's keys under a different help
// description. Volume adjustment reuses the global Left/Right binding (h/l,
// arrow keys), which is normally labelled "scroll left/right" — here it
// means something else, so the help bar needs its own wording.
func relabel(b key.Binding, desc string) key.Binding {
return key.NewBinding(key.WithKeys(b.Keys()...), key.WithHelp(b.Help().Key, desc))
}
func (k audioKeyMap) ShortHelp() []key.Binding {
g := keys.Keys.Global
a := keys.Keys.Audio
volDown, volUp := relabel(g.Left, "vol down"), relabel(g.Right, "vol up")
switch k.m.active {
case tabApps:
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, g.CycleFocus, g.Help}
default:
return []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Test, g.Help}
}
}
func (k audioKeyMap) FullHelp() [][]key.Binding {
g := keys.Keys.Global
a := keys.Keys.Audio
volDown, volUp := relabel(g.Left, "volume down"), relabel(g.Right, "volume up")
var bindings []key.Binding
switch k.m.active {
case tabOutput:
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, a.Test, a.Refresh, g.CycleFocus}
case tabInput:
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Select, a.Mute, relabel(a.Test, "test mic"), a.Refresh, g.CycleFocus}
case tabApps:
bindings = []key.Binding{g.Up, g.Down, volDown, volUp, a.Mute, a.Refresh, g.CycleFocus}
}
bindings = append(bindings, g.CommonBindings()...)
return keys.ChunkByWidth(bindings, k.width)
}
+46
View File
@@ -0,0 +1,46 @@
package audio
import "github.com/anotherhadi/settuings/internal/audio"
// checkMsg carries the result of audio.CheckAvailable.
type checkMsg struct{ err error }
// tickMsg triggers a periodic refresh of every tab while this page is active.
type tickMsg struct{}
type outputListMsg struct {
devices []audio.Device
err error
}
type inputListMsg struct {
devices []audio.Device
err error
}
type appsListMsg struct {
streams []audio.Stream
err error
}
// outputActionMsg carries the result of a control action (set volume, mute,
// set default, play test tone) performed on the Output tab.
type outputActionMsg struct{ err error }
// inputActionMsg is the Input tab's equivalent of outputActionMsg.
type inputActionMsg struct{ err error }
// appsActionMsg is the Apps tab's equivalent of outputActionMsg.
type appsActionMsg struct{ err error }
// levelStartedMsg carries the result of starting a live input-level meter.
type levelStartedMsg struct {
monitor *audio.LevelMonitor
err error
}
// levelMsg carries one peak reading from the live input-level meter.
type levelMsg struct {
peak float64
err error
}
+106
View File
@@ -0,0 +1,106 @@
// Package audio provides the "Audio" TUI page: a thin, tabbed wrapper around
// wpctl (and the pw-play/pw-cat helper tools that ship with it) covering
// output devices, input devices, and per-application streams, in the same
// spirit as the network page's tabs.
package audio
import (
"charm.land/bubbles/v2/help"
tea "charm.land/bubbletea/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/icons"
)
type tab int
const (
tabOutput tab = iota
tabInput
tabApps
)
var visibleTabs = []tab{tabOutput, tabInput, tabApps}
func (t tab) label() string {
switch t {
case tabOutput:
return "Output"
case tabInput:
return "Input"
case tabApps:
return "Apps"
default:
return ""
}
}
func (t tab) icon() string {
switch t {
case tabOutput:
return icons.I.Output
case tabInput:
return icons.I.Input
case tabApps:
return icons.I.Apps
default:
return ""
}
}
type Model struct {
active tab
width int
height int
help help.Model
checkErr error // reason wpctl is unusable; nil once confirmed OK
checked bool
output outputState
input inputState
apps appsState
}
func New() Model {
return Model{
active: tabOutput,
help: ilovetui.NewHelp(),
output: newOutputState(),
input: newInputState(),
apps: newAppsState(),
}
}
func (m Model) Init() tea.Cmd {
return nil
}
// Activate is called every time the user navigates onto this page. It
// re-checks wpctl availability, kicks off a fetch for every tab, and starts
// the periodic auto-refresh loop.
func (m *Model) Activate() tea.Cmd {
return tea.Batch(
checkCmd(),
fetchOutputCmd(),
fetchInputCmd(),
fetchAppsCmd(),
tickCmd(),
)
}
// Deactivate is called synchronously when the user navigates away from this
// page (or quits) so the live input-level meter, if running, never leaks a
// background pw-cat process.
func (m *Model) Deactivate() {
m.input.stopLevelMonitor()
}
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)
}
+165
View File
@@ -0,0 +1,165 @@
package audio
import (
"strings"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/audio"
"github.com/anotherhadi/settuings/internal/keys"
)
const volumeStep = 5
type outputState struct {
loading bool
err error
devices []audio.Device
cursor int
pending bool
feedback string
}
func newOutputState() outputState {
return outputState{loading: true}
}
func (m *Model) handleOutputList(msg outputListMsg) {
m.output.loading = false
m.output.err = msg.err
if msg.err != nil {
return
}
m.output.devices = msg.devices
if m.output.cursor >= len(m.output.devices) {
m.output.cursor = max(0, len(m.output.devices)-1)
}
}
func (m *Model) handleOutputAction(msg outputActionMsg) tea.Cmd {
m.output.pending = false
if msg.err != nil {
m.output.feedback = msg.err.Error()
return nil
}
return fetchOutputCmd()
}
func (m Model) selectedOutput() *audio.Device {
if m.output.cursor < 0 || m.output.cursor >= len(m.output.devices) {
return nil
}
return &m.output.devices[m.output.cursor]
}
func (m *Model) updateOutput(msg tea.Msg) tea.Cmd {
keyMsg, ok := msg.(tea.KeyPressMsg)
if !ok {
return nil
}
g := keys.Keys.Global
a := keys.Keys.Audio
switch {
case key.Matches(keyMsg, g.Up):
if m.output.cursor > 0 {
m.output.cursor--
}
case key.Matches(keyMsg, g.Down):
if m.output.cursor < len(m.output.devices)-1 {
m.output.cursor++
}
case key.Matches(keyMsg, g.Left):
return m.adjustOutputVolume(-volumeStep)
case key.Matches(keyMsg, g.Right):
return m.adjustOutputVolume(volumeStep)
case key.Matches(keyMsg, a.Refresh):
m.output.pending = true
return fetchOutputCmd()
case key.Matches(keyMsg, a.Mute):
dev := m.selectedOutput()
if dev == nil {
return nil
}
m.output.feedback = ""
m.output.pending = true
return outputToggleMuteCmd(dev.ID)
case key.Matches(keyMsg, a.Select):
dev := m.selectedOutput()
if dev == nil {
return nil
}
if dev.Default {
m.output.feedback = "Already the default output."
return nil
}
m.output.feedback = ""
m.output.pending = true
return outputSetDefaultCmd(dev.ID)
case key.Matches(keyMsg, a.Test):
dev := m.selectedOutput()
if dev == nil {
return nil
}
return tea.Batch(outputTestToneCmd(dev.ID), notifyCmd("Audio", "Playing test sound on "+dev.Name+"…"))
}
return nil
}
// adjustOutputVolume nudges the selected device's volume by delta, updating
// it locally right away so the bar responds instantly instead of waiting on
// the round trip to wpctl and back.
func (m *Model) adjustOutputVolume(delta int) tea.Cmd {
dev := m.selectedOutput()
if dev == nil {
return nil
}
pct := dev.Volume + delta
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
dev.Volume = pct
m.output.feedback = ""
return outputSetVolumeCmd(dev.ID, pct)
}
func (m Model) renderOutput(width, height int) string {
if m.output.loading {
return ilovetui.S.Faint.Render("Loading output devices…")
}
header := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Subtle).Render("OUTPUT DEVICES")
if m.output.pending {
header += mutedStyle().Render(" refreshing…")
}
if m.output.err != nil {
return lipgloss.JoinVertical(lipgloss.Left, header, "", errorStyle().Render(m.output.err.Error()))
}
if len(m.output.devices) == 0 {
return lipgloss.JoinVertical(lipgloss.Left, header, "", ilovetui.S.Faint.Render("No output devices found."))
}
available := (height - 3) / deviceRowHeight
if available < 1 {
available = 1
}
p, start, end := paginate(m.output.cursor, len(m.output.devices), available)
var rows []string
for i := start; i < end; i++ {
rows = append(rows, renderDeviceRow(m.output.devices[i], i == m.output.cursor, width))
}
list := strings.Join(rows, "\n\n")
footer := renderPaginatorDots(p)
if m.output.feedback != "" {
footer = subtleStyle().Render(m.output.feedback)
}
return lipgloss.JoinVertical(lipgloss.Left, header, "", list, footer)
}
+44
View File
@@ -0,0 +1,44 @@
package audio
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())
}
+62
View File
@@ -0,0 +1,62 @@
package audio
import (
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/audio"
"github.com/anotherhadi/settuings/internal/icons"
)
// deviceRowHeight and streamRowHeight are the number of lines each list row
// occupies (name line + bar line + one blank spacer line), used to size how
// many rows fit in the available height.
const (
deviceRowHeight = 3
streamRowHeight = 3
)
// barWidth derives a volume-bar width from the row's available width,
// leaving room for the name line's cursor/default marker on one side and the
// percentage/MUTED label on the other.
func barWidth(rowWidth int) int {
w := rowWidth - 24
switch {
case w > 30:
w = 30
case w < 10:
w = 10
}
return w
}
func renderDeviceRow(d audio.Device, selected bool, width int) string {
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
if selected {
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
}
cursor := " "
if selected {
cursor = "> "
}
name := textStyle.Render(d.Name)
if d.Default {
name += " " + successStyle().Render(icons.I.Check+"default")
}
nameLine := cursor + name
barLine := " " + renderVolumeBar(d.Volume, barWidth(width), d.Muted)
return nameLine + "\n" + barLine
}
func renderStreamRow(s audio.Stream, selected bool, width int) string {
textStyle := lipgloss.NewStyle().Foreground(ilovetui.S.Text)
if selected {
textStyle = textStyle.Bold(true).Foreground(ilovetui.S.Primary)
}
cursor := " "
if selected {
cursor = "> "
}
nameLine := cursor + textStyle.Render(s.App)
barLine := " " + renderVolumeBar(s.Volume, barWidth(width), s.Muted)
return nameLine + "\n" + barLine
}
+61
View File
@@ -0,0 +1,61 @@
package audio
import (
"fmt"
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
// renderVolumeBar draws a static block-bar slider. bubbles has no dedicated
// slider widget, only an animated progress bar meant for async progress, not
// an immediately-adjustable value — a static bar reflects every keypress
// instantly without depending on progress.Model's frame-tick animation.
func renderVolumeBar(pct, barWidth int, muted bool) string {
if barWidth < 4 {
barWidth = 4
}
filled := pct * barWidth / 100
switch {
case filled > barWidth:
filled = barWidth
case filled < 0:
filled = 0
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
pctText := fmt.Sprintf("%3d%%", pct)
barColor := ilovetui.S.Primary
if muted {
barColor = ilovetui.S.Subtle
}
line := lipgloss.NewStyle().Foreground(barColor).Render("["+bar+"]") + " " + subtleStyle().Render(pctText)
if muted {
line += " " + errorStyle().Bold(true).Render("MUTED")
}
return line
}
// renderLevelMeter draws a live input-level meter from a peak value in
// [0, 1], used to test a microphone.
func renderLevelMeter(peak float64, barWidth int) string {
if barWidth < 4 {
barWidth = 4
}
filled := int(peak*float64(barWidth) + 0.5)
switch {
case filled > barWidth:
filled = barWidth
case filled < 0:
filled = 0
}
color := ilovetui.S.Success
if peak > 0.9 {
color = ilovetui.S.Error
} else if peak > 0.65 {
color = ilovetui.S.Warning
}
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
return "Level: " + lipgloss.NewStyle().Foreground(color).Render("["+bar+"]")
}
+22
View File
@@ -0,0 +1,22 @@
package audio
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)
}
+49
View File
@@ -0,0 +1,49 @@
package audio
import (
"strings"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
)
// cycleTab moves to the next tab, stopping any live input-level test running
// on the tab being left.
func (m *Model) cycleTab() {
m.input.stopLevelMonitor()
for i, t := range visibleTabs {
if t == m.active {
m.active = visibleTabs[(i+1)%len(visibleTabs)]
return
}
}
}
func (m Model) renderTabBar(width int) string {
active := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Primary).Padding(0, 2).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(ilovetui.S.Primary)
inactive := lipgloss.NewStyle().Foreground(ilovetui.S.Subtle).Padding(0, 2).
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(ilovetui.S.Subtle)
var tabs []string
for _, t := range visibleTabs {
style := inactive
if t == m.active {
style = active
}
tabs = append(tabs, style.Render(t.icon()+t.label()))
}
row := lipgloss.JoinHorizontal(lipgloss.Bottom, tabs...)
filler := width - lipgloss.Width(row)
if filler > 0 {
row = lipgloss.JoinHorizontal(lipgloss.Bottom, row,
lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), false, false, true, false).
BorderForeground(ilovetui.S.Subtle).
Render(strings.Repeat(" ", filler)))
}
return row
}
+79
View File
@@ -0,0 +1,79 @@
package audio
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 + the auto-refresh loop
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
}
return m, nil
case tickMsg:
if m.checkErr != nil {
return m, nil
}
return m, tea.Batch(fetchOutputCmd(), fetchInputCmd(), fetchAppsCmd(), tickCmd())
case outputListMsg:
m.handleOutputList(msg)
return m, nil
case outputActionMsg:
return m, m.handleOutputAction(msg)
case inputListMsg:
m.handleInputList(msg)
return m, nil
case inputActionMsg:
return m, m.handleInputAction(msg)
case levelStartedMsg:
return m, m.handleLevelStarted(msg)
case levelMsg:
return m, m.handleLevel(msg)
case appsListMsg:
m.handleAppsList(msg)
return m, nil
case appsActionMsg:
return m, m.handleAppsAction(msg)
case tea.KeyPressMsg:
if m.checkErr != nil {
if key.Matches(msg, keys.Keys.Audio.Refresh) {
return m, checkCmd()
}
return m, nil
}
g := keys.Keys.Global
switch {
case key.Matches(msg, g.CycleFocus):
m.cycleTab()
return m, nil
case key.Matches(msg, g.Help):
m.help.ShowAll = !m.help.ShowAll
return m, nil
}
var cmd tea.Cmd
switch m.active {
case tabOutput:
cmd = m.updateOutput(msg)
case tabInput:
cmd = m.updateInput(msg)
case tabApps:
cmd = m.updateApps(msg)
}
return m, cmd
}
return m, nil
}
+85
View File
@@ -0,0 +1,85 @@
package audio
import (
"errors"
"strings"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
ilovetui "github.com/anotherhadi/ilovetui"
"github.com/anotherhadi/settuings/internal/audio"
)
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()
statusH := strings.Count(statusBar, "\n") + 1
frameW := windowStyle().GetHorizontalFrameSize()
frameH := windowStyle().GetVerticalFrameSize()
innerW := m.width - frameW
tabBar := m.renderTabBar(innerW)
tabBarH := lipgloss.Height(tabBar)
innerH := m.height - tabBarH - frameH - statusH
var body string
switch {
case !m.checked:
body = ilovetui.S.Faint.Render("Checking wpctl…")
case m.checkErr != nil:
body = m.renderUnavailable()
default:
body = m.renderActiveTab(innerW, innerH)
}
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 := audioKeyMap{m: m, width: m.width}
return lipgloss.NewStyle().Padding(0, 1).Render(m.help.View(km))
}
func (m Model) renderActiveTab(width, height int) string {
switch m.active {
case tabOutput:
return m.renderOutput(width, height)
case tabInput:
return m.renderInput(width, height)
case tabApps:
return m.renderApps(width, height)
default:
return ""
}
}
func (m Model) renderUnavailable() string {
title := lipgloss.NewStyle().Bold(true).Foreground(ilovetui.S.Error).Render("Audio page unavailable")
var hint string
switch {
case errors.Is(m.checkErr, audio.ErrNotFound):
hint = "wpctl was not found in PATH. Install PipeWire/WirePlumber to use this page."
default:
hint = m.checkErr.Error()
}
return lipgloss.JoinVertical(lipgloss.Left,
title,
"",
subtleStyle().Render(hint),
"",
mutedStyle().Render("Press r to try again once fixed."),
)
}