From 318b2a8443677870fd68f4a80ae7e448043b97da Mon Sep 17 00:00:00 2001 From: server2 Date: Sat, 10 Oct 2026 12:13:34 +0200 Subject: [PATCH] control centre: menus for Wi-Fi, Bluetooth, VPN and Sound in their own window The tile or block grows into its menu; the panel fades its content and takes the menu's size underneath. Wi-Fi lists networks (join saved/open, disconnect, stop iwd), Bluetooth lists devices, VPN has a switch per openvpn service, Sound has every output with its own slider and a default picker. A second Sound slider for the output named by extra_output in shell.conf (NR515). Screen and keyboard light are written to sysfs directly: logind refuses a shell without a seat. --- README.md | 19 +- shell.conf | 3 + src/control.rs | 830 ++++++++++++++++++++++++++++++++++++++++++++++--- src/glyph.rs | 5 + src/island.rs | 4 +- src/main.rs | 1 + src/parse.rs | 27 ++ src/sound.rs | 65 +++- src/system.rs | 115 ++++++- src/theme.rs | 5 + 10 files changed, 1004 insertions(+), 70 deletions(-) diff --git a/README.md b/README.md index 5002fdd..6348592 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,14 @@ The pill shows Wi-Fi signal (the hotspot sign while it's on), the VPN lock (acce The panel has: -- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on, grey = the service behind it isn't running. A grey tile is clickable when it can start that service. - - *Wi-Fi* (iwd): iwd stopped → grey, a click runs `rc-service iwd start`. iwd running → a click turns the device (station) on or off. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it. - - *Bluetooth* (BlueZ, through Amane's `Bluetooth`): on/off. With bluez installed but stopped, a click runs `rc-service bluetooth start`; without bluez the tile says so. - - *VPN*: the openrc services `/etc/init.d/openvpn.*` (here `openvpn.swiss`). Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A click runs `rc-service openvpn. start|stop`. +- **Four tiles:** Wi-Fi, Bluetooth, VPN, Hotspot. Accent = on, grey = the service behind it isn't running. Wi-Fi, Bluetooth and VPN open their menu when clicked anywhere; Hotspot switches at once. + - *Wi-Fi* (iwd): iwd stopped → grey; the switch in its menu runs `rc-service iwd start`. iwd running → the switch turns the device (station) on or off. The menu lists the networks in range (connected first, then by signal): click the connected one to disconnect, a saved or open one to join. A new network with a password has to be joined once with `iwctl` (the menu can't ask for passwords yet). "Stop the Wi-Fi service" at the bottom runs `rc-service iwd stop`. The shell never wakes iwd by asking it anything: it checks `NameHasOwner` first, because any D-Bus call to `net.connman.iwd` starts it. + - *Bluetooth* (BlueZ, through Amane's `Bluetooth`): the switch powers the adapter; with bluez installed but stopped it runs `rc-service bluetooth start`. The menu scans while it's open and lists the devices: click to connect, disconnect, or pair (pairing works for devices without a PIN). + - *VPN*: every openrc service `/etc/init.d/openvpn.*` (here `openvpn.swiss`), each with a switch. Connecting while openrc has it *inactive*, connected once its `up.sh` marks it *started*. A switch runs `rc-service openvpn. start|stop`. - *Hotspot* (iwd access point): see [Hotspot setup](#hotspot-setup). -- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. -- **Display:** the laptop screen's brightness (through logind, like Amane's `Brightness`) and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High). For external monitors see [External monitor brightness](#external-monitor-brightness). +- **Menus:** each opens in a window of its own over the panel and grows out of its tile; the panel takes the menu's size underneath, so the two lie on top of each other. The back button returns to the panel; moving the pointer off both closes everything. +- **Sound:** the default output's volume (drag or scroll the slider); the value next to the title is the mute button. With `extra_output` in `shell.conf` a second slider follows for the output whose name contains it (here `NR515`: the `[TX-NR515]` receiver on HDMI 1). Clicking the block elsewhere opens the Sound menu: every output with its own slider and mute; click a name to make it the default output. +- **Display:** the laptop screen's brightness and the keyboard light (`tpacpi::kbd_backlight`: Off, Low, High), written straight to their sysfs files (a udev rule makes them writable by everyone on this laptop), else through logind, which refuses a shell without a seat (one started over ssh). For external monitors see [External monitor brightness](#external-monitor-brightness). - **Performance:** the firmware profiles in `/sys/firmware/acpi/platform_profile_choices` (Low power, Balanced, Performance), and the battery's state and time left. Picking one sets the platform profile and the CPU governor and energy preference with it: Low power = `powersave` + `power`, Balanced = `powersave` + `balance_performance`, Performance = the `performance` governor. - **External GPU:** the GPU the machine didn't boot with (`boot_vga = 0`, here the Radeon RX 6600 in the Thunderbolt enclosure): load, temperature, power draw, video memory, and every program that has it open with its video memory (and its share of the GPU while the driver reports engine time). Found through `/proc//fdinfo` (`drm-pdev`), only while the panel is open. Root's processes can't be read and don't show; the shell itself shows up, because Vulkan opens every GPU. @@ -34,7 +35,8 @@ Changes that need root (starting services, the performance mode) run as `doas -n ```sh amane ipc call control toggle # also: open, close -amane ipc call control status # expanded= wifi= vpn= profile=

battery= +amane ipc call control menu wifi # also: bluetooth, vpn, sound, close +amane ipc call control status # expanded= menu= wifi= vpn= profile=

battery= ``` ### Hotspot setup @@ -100,6 +102,7 @@ The Material You scheme type is Skwd-wall's setting (`matugen.schemeType`). cover_colors = false # true: the open media card and equalizer take the song cover's colours as background transition_ms = 400 # how long a colour change cross-fades; 0 switches instantly hotspot = MyHotspot # the iwd access point profile the control centre's hotspot starts +extra_output = NR515 # a second Sound slider for the output whose name contains this ``` Keep comments on lines of their own in the real file: a `#` after a value becomes part of it. @@ -119,7 +122,7 @@ src/glyph.rs every drawn icon src/theme.rs the shell's colours: the matugen theme file, shell.conf, the cross-fade src/parse.rs pure text parsing with unit tests, no amane dependency matugen/ the matugen template (amane.colors), rendered by skwd-wall into ./theme -shell.conf settings: cover_colors, transition_ms, hotspot +shell.conf settings: cover_colors, transition_ms, hotspot, extra_output island-eq saved equalizer curves (a snapshot of ~/.config/amane/island-eq) tools/ remote test driver and fake MPRIS players, see AGENTS.md ``` diff --git a/shell.conf b/shell.conf index 61ceacc..90ff814 100644 --- a/shell.conf +++ b/shell.conf @@ -11,3 +11,6 @@ transition_ms = 400 # the control centre's hotspot: the name of an iwd access point profile, /var/lib/iwd/ap/.ap # (see README "Control centre"); leave it out and the hotspot tile says so # hotspot = MyHotspot + +# a second slider under Sound in the control centre, for the output whose name contains this +extra_output = NR515 diff --git a/src/control.rs b/src/control.rs index 91d1131..06800eb 100644 --- a/src/control.rs +++ b/src/control.rs @@ -16,12 +16,25 @@ use amane::{ }; use crate::glyph::{Glyph, icon}; -use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, COMPACT_H, HOVER_MS, SHADOW, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled}; +use crate::island::{self, CHIP, CHIP_HOVER, CHIP_OPEN, CLEAR, COMPACT_H, HOVER_MS, PILL_GONE, SHADOW, SHAPE_DONE, TOP_GAP, TRACK, backdrop, faded, mix, phases, veiled}; +use crate::parse; +use crate::sound::Sound; use crate::system::{self, Charge, Gpu, Links, Power, VpnState}; use crate::theme::{self, Theme}; const OPEN_MS: u64 = 520; +// a menu grows out of the tile it belongs to, on its own window over the panel +const MENU_MS: u64 = 380; +const MENU_PAD: f32 = 12.0; +const MENU_W: f32 = PANEL_W - 2.0 * MENU_PAD; +const MENU_HEADER_H: f32 = 28.0; +const MENU_ROW_H: f32 = 44.0; +const MENU_ROWS: usize = 7; +const NOTE_H: f32 = 18.0; +const OUTPUT_H: f32 = 22.0; +const OUTPUTS: usize = 8; + // the pill and the panel sit this far from the monitor's right edge const RIGHT: f32 = 12.0; @@ -72,6 +85,18 @@ pub enum Tile { enum Slider { Volume, Screen, + + // one output, by its pactl sink index + Output(u32), +} + +// the menus a tile or block opens +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub enum Menu { + Wifi, + Bluetooth, + Vpn, + Sound, } // everything that lights up under the pointer @@ -83,6 +108,12 @@ enum Spot { Mute, Light(u32), Profile(usize), + Sound, + Back, + Switch, + Row(usize), + Footer, + OutputMute(u32), } pub struct Control { @@ -99,6 +130,15 @@ pub struct Control { // a clicked tile, with what it said then: it shows "One moment" until that changes or the action is done busy: HashMap, + + // the menu shown over the panel (kept while it shrinks away), whether it's wanted, and its own timeline + menu: Option

, + menu_up: bool, + menu_open: Animation, + + // the pointer is over the panel's window or the menu's; it closes only once it has left both + inside_panel: bool, + inside_menu: bool, } impl Service for Control { @@ -111,6 +151,11 @@ impl Service for Control { hover: HashMap::new(), held: HashMap::new(), busy: HashMap::new(), + menu: None, + menu_up: false, + menu_open: menu_opening(), + inside_panel: false, + inside_menu: false, } } @@ -163,6 +208,7 @@ fn hovering(spot: Spot) -> impl Fn(bool) + 'static { // what decides the panel's height: the blocks this machine has #[derive(Clone, Copy)] struct Layout { + extra_output: bool, screen: bool, keyboard: bool, profiles: bool, @@ -171,7 +217,13 @@ struct Layout { } impl Layout { - const LARGEST: Layout = Layout { screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) }; + const LARGEST: Layout = Layout { extra_output: true, screen: true, keyboard: true, profiles: true, battery: true, gpu_rows: Some(MAX_ROWS) }; + + fn sound(&self) -> f32 { + let extra: &[f32] = if self.extra_output { &[TITLE_H, SLIDER_H] } else { &[] }; + + module_height(&[&[TITLE_H, SLIDER_H], extra].concat()).unwrap_or(0.0) + } fn display(&self) -> Option { let parts: Vec = [self.screen.then_some(TITLE_H), self.screen.then_some(SLIDER_H), self.keyboard.then_some(KEYS_H)].into_iter().flatten().collect(); @@ -190,7 +242,7 @@ impl Layout { } fn height(&self) -> f32 { - let modules: f32 = [Some(sound_height()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| GAP + height).sum(); + let modules: f32 = [Some(self.sound()), self.display(), self.performance(), self.gpu()].into_iter().flatten().map(|height| GAP + height).sum(); 2.0 * PAD + 2.0 * TILE_H + GAP + modules } @@ -214,6 +266,7 @@ struct Scene<'a> { screen: &'a Brightness, gpu: &'a Gpu, hotspot: &'a str, + extra_output: Option<&'a parse::Sink>, layout: Layout, } @@ -254,8 +307,15 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let screen = open.then(Brightness::read); let gpu = open.then(Gpu::read); let theme_settings = open.then(Theme::read); + let sound = open.then(Sound::read); + + let extra_output = match (&sound, &theme_settings) { + (Some(sound), Some(settings)) => sound.output_named(&settings.extra_output), + _ => None, + }; let layout = Layout { + extra_output: extra_output.is_some(), screen: screen.as_deref().is_some_and(Brightness::present), keyboard: power.keyboard.is_some(), profiles: !power.profiles.is_empty(), @@ -263,15 +323,35 @@ pub fn view(monitor: &Monitor) -> LayerWindow { gpu_rows: gpu.as_deref().filter(|gpu| !gpu.name.is_empty()).map(|gpu| gpu.programs.len().min(MAX_ROWS)), }; + /* + * a menu over the panel (its own window, menu_view): on the menu's timeline the + * panel's content fades out first (0 to PILL_GONE), then the empty panel takes the + * menu's height (to SHAPE_DONE) while the menu grows out of its tile; settled, the + * menu lies exactly on the panel + */ + let menu_t = if opened_here { control.menu_open.value() } else { 0.0 }; + + let menu_h = match (control.menu.filter(|_| menu_t > 0.0), &sound) { + (Some(menu), Some(sound)) => Some(menu_height(&menu_content(menu, &control, &links, &bluetooth, sound, layout.extra_output).0)), + _ => None, + }; + + let fade = if menu_h.is_some() { 1.0 - (menu_t / PILL_GONE).clamp(0.0, 1.0) } else { 1.0 }; + + let morph = ((menu_t - PILL_GONE) / (SHAPE_DONE - PILL_GONE)).clamp(0.0, 1.0); + let morph = morph * morph * (3.0 - 2.0 * morph); + + let full_h = menu_h.map_or(layout.height(), |menu_h| mix(layout.height(), menu_h, morph)); + let width = mix(PILL_W + PILL_HOVER_GROW * lift, PANEL_W, grow); - let height = mix(COMPACT_H, layout.height(), grow); + let height = mix(COMPACT_H, full_h, grow); let radius = mix(COMPACT_H / 2.0, PANEL_RADIUS, grow); let colors = theme::now().surfaces(); // at most one face at a time, each faded by a veil instead of a gpu layer let face = match (&audio, &screen, &gpu, &theme_settings) { - (Some(audio), Some(screen), Some(gpu), Some(settings)) if reveal > 0.0 => { + (Some(audio), Some(screen), Some(gpu), Some(settings)) if reveal > 0.0 && fade > 0.0 => { let scene = Scene { control: &control, links: &links, @@ -281,13 +361,14 @@ pub fn view(monitor: &Monitor) -> LayerWindow { screen, gpu, hotspot: &settings.hotspot, + extra_output, layout, }; - veiled(panel(&scene), width, height, radius, reveal, &colors) + veiled(panel(&scene), width, height, radius, reveal * fade, &colors) } _ if pill > 0.0 => veiled(compact(&links, &bluetooth, &power), width, height, radius, pill, &colors), - // mid-grow: the empty shape alone + // mid-grow, or under a menu: the empty shape alone _ => Rectangle::new().width(0.0).height(0.0), }; @@ -304,8 +385,14 @@ pub fn view(monitor: &Monitor) -> LayerWindow { .align_child(Center, Start) .on_click(move |_| tap(&tapped)) .on_hover(move |inside| { - if inside { - Control::write().pointer_on = Some(hovered.clone()); + { + let mut control = Control::write(); + + if inside { + control.pointer_on = Some(hovered.clone()); + } + + control.inside_panel = inside; } hovering(Spot::Pill)(inside); @@ -314,7 +401,7 @@ pub fn view(monitor: &Monitor) -> LayerWindow { let panel_here = Control::read().opened_on.as_deref().is_none_or(|name| name == hovered); if !inside && panel_here { - collapse(); + collapse_soon(); } }) .child(face); @@ -373,10 +460,88 @@ fn collapse() { // the panel's buttons are about to go, see hovering control.hover.retain(|spot, _| *spot == Spot::Pill); + + drop(control); + + close_menu(); } /* - * `amane ipc call control ...` for keybinds and tests: open | close | toggle | status + * the pointer left the panel or its menu: they're two windows, and going from one to + * the other leaves the first before it enters the second, so wait a moment and close + * only if it's over neither + */ +fn collapse_soon() { + thread::spawn(|| { + thread::sleep(Duration::from_millis(250)); + + let gone = { + let control = Control::read(); + + !control.inside_panel && !control.inside_menu + }; + + if gone { + collapse(); + } + }); +} + +fn menu_opening() -> Animation { + Animation::new(0.0).duration(Duration::from_millis(MENU_MS)).easing(Easing::Linear) +} + +// grows the menu out of its tile, and asks for fresh lists while it's open +fn open_menu(menu: Menu) { + { + let mut control = Control::write(); + + // another menu, or one that has already gone: grow again from its own tile + if control.menu != Some(menu) || control.menu_open.value() == 0.0 { + control.menu_open = menu_opening(); + } + + control.menu = Some(menu); + control.menu_up = true; + control.menu_open.to(1.0); + + // it opens under the pointer that clicked its tile, which may not get an enter event until it moves + control.inside_menu = true; + } + + match menu { + Menu::Wifi => { + thread::spawn(system::scan_wifi); + } + Menu::Bluetooth if Bluetooth::read().available() => Bluetooth::start_scan(), + _ => {} + } +} + +fn close_menu() { + let menu = { + let mut control = Control::write(); + + if !control.menu_up { + return; + } + + control.menu_up = false; + control.menu_open.to(0.0); + control.inside_menu = false; + control.hover.retain(|spot, _| !matches!(spot, Spot::Back | Spot::Switch | Spot::Row(_) | Spot::Footer | Spot::OutputMute(_))); + + control.menu + }; + + if menu == Some(Menu::Bluetooth) && Bluetooth::read().available() { + Bluetooth::stop_scan(); + } +} + +/* + * `amane ipc call control ...` for keybinds and tests: + * open | close | toggle | menu wifi|bluetooth|vpn|sound|close | status */ pub fn command(arguments: &[String]) -> String { let words: Vec<&str> = arguments.iter().map(String::as_str).collect(); @@ -389,15 +554,43 @@ pub fn command(arguments: &[String]) -> String { if expanded { collapse() } else { expand() } } + ["menu", "close"] => close_menu(), + ["menu", which] => { + let menu = match *which { + "wifi" => Menu::Wifi, + "bluetooth" => Menu::Bluetooth, + "vpn" => Menu::Vpn, + "sound" => Menu::Sound, + _ => return String::from("usage: control menu wifi|bluetooth|vpn|sound|close"), + }; + + if !Control::read().expanded { + expand(); + } + + open_menu(menu); + } ["status"] => return status(), - _ => return String::from("usage: control open|close|toggle|status"), + _ => return String::from("usage: control open|close|toggle|menu |status"), } String::from("ok") } fn status() -> String { - let expanded = Control::read().expanded; + let (expanded, menu) = { + let control = Control::read(); + + let menu = match control.menu.filter(|_| control.menu_up) { + Some(Menu::Wifi) => "wifi", + Some(Menu::Bluetooth) => "bluetooth", + Some(Menu::Vpn) => "vpn", + Some(Menu::Sound) => "sound", + None => "none", + }; + + (control.expanded, menu) + }; let (wifi, vpn) = { let links = Links::read(); @@ -425,7 +618,7 @@ fn status() -> String { let power = Power::read(); let battery = power.battery.as_ref().map_or(String::from("none"), |charge| format!("{}%", charge.percent)); - format!("expanded={expanded} wifi={wifi} vpn={vpn} profile={} battery={battery}", power.profile) + format!("expanded={expanded} menu={menu} wifi={wifi} vpn={vpn} profile={} battery={battery}", power.profile) } // the pill: wi-fi (or the hotspot), vpn, bluetooth, battery @@ -518,7 +711,9 @@ fn panel(scene: &Scene) -> Rectangle { blocks.push(Box::new(gpu(scene, height))); } - Rectangle::new().width(PANEL_W).height(scene.layout.height()).padding(PAD).child(Column::new(blocks).gap(GAP)) + let height = scene.layout.height(); + + Rectangle::new().width(PANEL_W).height(height).padding(PAD).child(Column::new(blocks).gap(GAP)) } fn module(height: f32, content: Column) -> Rectangle { @@ -565,9 +760,15 @@ struct State { fn tiles(scene: &Scene) -> Column { let row = |left: Rectangle, right: Rectangle| Row::new(children![left, right]).gap(GAP); + let control = scene.control; + + // Wi-Fi, Bluetooth and VPN open their menu; the hotspot just switches Column::new(children![ - row(tile(scene.control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links)), tile(scene.control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene))), - row(tile(scene.control, Tile::Vpn, "VPN", vpn_state(scene.links)), tile(scene.control, Tile::Hotspot, "Hotspot", hotspot_state(scene))), + row( + tile(control, Tile::Wifi, "Wi-Fi", wifi_state(scene.links), Some(Menu::Wifi)), + tile(control, Tile::Bluetooth, "Bluetooth", bluetooth_state(scene.bluetooth, scene.links), Some(Menu::Bluetooth)), + ), + row(tile(control, Tile::Vpn, "VPN", vpn_state(scene.links), Some(Menu::Vpn)), tile(control, Tile::Hotspot, "Hotspot", hotspot_state(scene), None)), ]) .gap(GAP) } @@ -576,7 +777,7 @@ fn wifi_state(links: &Links) -> State { let wifi = &links.wifi; let (look, subtitle) = if !wifi.service { - (Look::Grey, String::from("Click to start")) + (Look::Grey, String::from("Service off")) } else if wifi.device.is_empty() { (Look::Grey, String::from("No device")) } else if wifi.access_point { @@ -599,9 +800,7 @@ fn wifi_state(links: &Links) -> State { State { look, glyph: Glyph::Wifi(3), subtitle, action } } -fn bluetooth_state(scene: &Scene) -> State { - let bluetooth = scene.bluetooth; - +fn bluetooth_state(bluetooth: &Bluetooth, links: &Links) -> State { let (look, subtitle, action) = if bluetooth.available() && bluetooth.powered() { let connected: Vec<&str> = bluetooth.devices().iter().filter(|device| device.connected()).map(|device| device.name()).collect(); @@ -610,8 +809,8 @@ fn bluetooth_state(scene: &Scene) -> State { (Look::On, subtitle, Some(Action::Bluetooth)) } else if bluetooth.available() { (Look::Off, String::from("Off"), Some(Action::Bluetooth)) - } else if scene.links.bluetooth_service { - (Look::Grey, String::from("Click to start"), Some(Action::Bluetooth)) + } else if links.bluetooth_service { + (Look::Grey, String::from("Service off"), Some(Action::Bluetooth)) } else { (Look::Grey, String::from("Not installed"), None) }; @@ -658,7 +857,7 @@ fn hotspot_state(scene: &Scene) -> State { State { look, glyph: Glyph::Hotspot, subtitle, action } } -fn tile(control: &Control, which: Tile, title: &'static str, state: State) -> Rectangle { +fn tile(control: &Control, which: Tile, title: &'static str, state: State, menu: Option) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Tile(which)); @@ -697,6 +896,11 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State) -> Re .align(Center), ); + // the whole tile opens its menu, where the switch is; tiles without one switch at once + if let Some(menu) = menu { + return tile.cursor(Pointer).on_click(move |_| open_menu(menu)); + } + match state.action { Some(action) if !busy => { let before = state.subtitle; @@ -711,13 +915,20 @@ fn tile(control: &Control, which: Tile, title: &'static str, state: State) -> Re fn act(tile: Tile, before: String, action: Action) { Control::write().busy.insert(tile, (Instant::now(), before)); - thread::spawn(move || { + later(move || { match action { Action::Wifi => system::toggle_wifi(), Action::Bluetooth => system::toggle_bluetooth(), Action::Vpn(service) => system::toggle_vpn(&service), Action::Hotspot => system::toggle_hotspot(), } + }, Some(tile)); +} + +// slow work on its own thread, then fresh state a few times while the daemons answer +fn later(work: impl FnOnce() + Send + 'static, tile: Option) { + thread::spawn(move || { + work(); for wait in [200, 500, 800, 1500, 2500] { thread::sleep(Duration::from_millis(wait)); @@ -725,7 +936,9 @@ fn act(tile: Tile, before: String, action: Action) { system::refresh(); } - Control::write().busy.remove(&tile); + if let Some(tile) = tile { + Control::write().busy.remove(&tile); + } }); } @@ -760,14 +973,64 @@ fn sound(scene: &Scene) -> Rectangle { let glyph = if muted { Glyph::Muted } else { Glyph::Speaker }; - module( - sound_height(), - Column::new(children![header("Sound", Box::new(mute)), slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, set_volume)]).gap(LINE_GAP), - ) + let mut rows: Vec> = vec![ + Box::new(header("Sound", Box::new(mute))), + Box::new(slider(scene.control, Slider::Volume, if muted { 0.0 } else { value }, glyph, MODULE_W, set_volume)), + ]; + + // a second output with its own slider (`extra_output` in shell.conf), e.g. the AV receiver + if let Some(sink) = scene.extra_output { + rows.push(Box::new(output_label(scene.control, sink, MODULE_W))); + rows.push(Box::new(output_slider(scene.control, sink, MODULE_W))); + } + + // the whole block opens the Sound menu; its slider and chips take their own clicks first + let lit = scene.control.lit(Spot::Sound); + + module(scene.layout.sound(), Column::new(rows).gap(LINE_GAP)) + .fill(Color::blend(CHIP, CHIP_HOVER, 0.5 * lit)) + .cursor(Pointer) + .on_hover(hovering(Spot::Sound)) + .on_click(|_| open_menu(Menu::Sound)) } -fn sound_height() -> f32 { - module_height(&[TITLE_H, SLIDER_H]).unwrap_or(0.0) +// an output's name and its volume, the volume being its mute button +fn output_label(control: &Control, sink: &parse::Sink, width: f32) -> Row { + let theme = theme::now(); + + let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 34); + + Row::new(children![Text::new(title).size(11.0).color(theme.dim).elide(), output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(width).height(TITLE_H) +} + +fn output_mute(control: &Control, sink: &parse::Sink) -> Rectangle { + let value = control.slider(Slider::Output(sink.index), f32::from(sink.volume.min(100)) / 100.0); + let label = if sink.muted { String::from("Muted") } else { format!("{}%", (value * 100.0).round()) }; + + let lit = control.lit(Spot::OutputMute(sink.index)); + let name = sink.name.clone(); + + Rectangle::new() + .width(label_width(&label, 11.0, 14.0)) + .height(TITLE_H) + .radius(6.0) + .fill(Color::blend(if sink.muted { CHIP_OPEN } else { CLEAR }, CHIP_HOVER, lit)) + .align_child(Center, Center) + .cursor(Pointer) + .on_hover(hovering(Spot::OutputMute(sink.index))) + .on_click(move |_| Sound::toggle_output_mute(&name)) + .child(Text::new(label).size(11.0).color(theme::now().text)) +} + +fn output_slider(control: &Control, sink: &parse::Sink, width: f32) -> Rectangle { + let value = control.slider(Slider::Output(sink.index), f32::from(sink.volume.min(100)) / 100.0); + let glyph = if sink.muted { Glyph::Muted } else if sink.bluetooth { Glyph::Bluetooth } else { Glyph::Speaker }; + + let name = sink.name.clone(); + + slider(control, Slider::Output(sink.index), if sink.muted { 0.0 } else { value }, glyph, width, move |value| { + Sound::set_output_volume(&name, (value * 100.0).round() as u8); + }) } fn display(scene: &Scene, height: f32) -> Rectangle { @@ -779,7 +1042,7 @@ fn display(scene: &Scene, height: f32) -> Rectangle { let value = scene.control.slider(Slider::Screen, f32::from(scene.screen.percent().min(100)) / 100.0); rows.push(Box::new(header("Display", Box::new(note(format!("{}%", (value * 100.0).round())))))); - rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, set_screen))); + rows.push(Box::new(slider(scene.control, Slider::Screen, value, Glyph::Sun, MODULE_W, set_screen))); } if let Some(light) = &scene.power.keyboard { @@ -828,35 +1091,39 @@ fn display(scene: &Scene, height: f32) -> Rectangle { /* * a wide slider in the macOS manner: a rounded track, filled in the accent up to the * value, with its icon at the left end. drag or scroll it; the shown value is held - * while the system catches up + * while the system catches up. it swallows its own clicks, so a press on it never + * reaches a clickable block around it */ -fn slider(control: &Control, which: Slider, value: f32, glyph: Glyph, set: fn(f32)) -> Rectangle { +fn slider(control: &Control, which: Slider, value: f32, glyph: Glyph, width: f32, set: impl Fn(f32) + Clone + 'static) -> Rectangle { let theme = theme::now(); let lit = control.lit(Spot::Slider(which)); - let fill_w = SLIDER_H + (MODULE_W - SLIDER_H) * value.clamp(0.0, 1.0); + let fill_w = SLIDER_H + (width - SLIDER_H) * value.clamp(0.0, 1.0); + + let dragged = set.clone(); Rectangle::new() - .width(MODULE_W) + .width(width) .height(SLIDER_H) .radius(Full) .fill(Color::blend(TRACK, CHIP_HOVER, lit)) .cursor(Pointer) .on_hover(hovering(Spot::Slider(which))) - .on_drag(move |point| slide(which, (point.x - SLIDER_H / 2.0) / (MODULE_W - SLIDER_H), set)) - .on_scroll(move |scroll| slide(which, value + if scroll.y < 0.0 { 0.05 } else { -0.05 }, set)) + .on_click(|_| {}) + .on_drag(move |point| slide(which, (point.x - SLIDER_H / 2.0) / (width - SLIDER_H), &dragged)) + .on_scroll(move |scroll| slide(which, value + if scroll.y < 0.0 { 0.05 } else { -0.05 }, &set)) .child( Stack::new(children![ Rectangle::new().width(fill_w).height(SLIDER_H).radius(Full).fill(Color::blend(theme.accent, theme.flame, 0.35 * lit)), Rectangle::new().width(SLIDER_H).height(SLIDER_H).align_child(Center, Center).child(icon(glyph, 14.0, theme.on_accent)), ]) - .width(MODULE_W) + .width(width) .height(SLIDER_H), ) } -fn slide(which: Slider, value: f32, set: fn(f32)) { +fn slide(which: Slider, value: f32, set: &impl Fn(f32)) { let value = value.clamp(0.0, 1.0); let unchanged = { @@ -882,9 +1149,11 @@ fn set_volume(value: f32) { Audio::set_volume((value * 100.0).round() as u8); } -// amane's Brightness never goes below 1%, so the screen never goes black +// the backlight file itself (logind refuses a shell without a seat), never below 1% fn set_screen(value: f32) { - Brightness::set(((value * 100.0).round() as u8).max(1)); + let percent = ((value * 100.0).round() as u8).max(1); + + thread::spawn(move || system::set_screen(percent)); } // a choice in a row of choices: the chosen one in the accent @@ -1050,3 +1319,478 @@ fn size(kib: u64) -> String { _ => format!("{:.1} GB", kib as f64 / 1_048_576.0), } } + +/* + * the menus: a window of their own per monitor, over the panel, so the panel never + * re-lays out. a menu grows out of the tile or block that opened it (the island's + * timeline again: the empty shape grows, then the content fades in) to the panel's + * width and its own height, and the panel takes that size underneath (see view). + * the back button or the pointer leaving both windows closes it + */ +pub fn menu_view(monitor: &Monitor) -> LayerWindow { + theme::begin(); + + let control = Control::read(); + + let window_w = RIGHT + PANEL_W + SHADOW; + let largest = Layout::LARGEST.height(); + + let window = LayerWindow::new() + .width(window_w) + .height(TOP_GAP + largest + SHADOW) + .anchor_vertical(Vertical::Top) + .anchor_horizontal(Horizontal::Right) + .layer(Layer::Overlay) + .space(Zone::Ignore) + .namespace("magic-control-menu"); + + let here = monitor.name.as_str(); + let on_here = control.opened_on.as_deref().is_none_or(|name| name == here); + let timeline = control.menu_open.value(); + + // never hidden (see the island's view): an empty click-through window instead + let Some(menu) = control.menu.filter(|_| on_here && timeline > 0.0) else { + return window.click_through().child(Rectangle::new().width(0.0).height(0.0)); + }; + + let links = Links::read(); + let bluetooth = Bluetooth::read(); + let sound = Sound::read(); + let extra = sound.output_named(&Theme::read().extra_output).is_some(); + + let (content, origin) = menu_content(menu, &control, &links, &bluetooth, &sound, extra); + + let menu_h = menu_height(&content); + + let (grow, _, reveal) = phases(timeline); + + let (x0, y0, w0, h0) = origin; + let (x, y) = (mix(x0, 0.0, grow), mix(y0, 0.0, grow)); + let (width, height) = (mix(w0, PANEL_W, grow), mix(h0, menu_h, grow)); + let radius = mix(MODULE_RADIUS, PANEL_RADIUS, grow); + + let colors = theme::now().surfaces(); + + let face = if reveal > 0.0 { + let page = Rectangle::new() + .width(PANEL_W) + .height(menu_h) + .padding(MENU_PAD) + .child(Column::new(children![menu_header(&control, content.title, content.right), content.body]).gap(GAP)); + + veiled(page, width, height, radius, reveal, &colors) + } else { + Rectangle::new().width(0.0).height(0.0) + }; + + let shape = Rectangle::new() + .width(width) + .height(height) + .radius(radius) + .fill(backdrop(&colors, 255)) + .border(1.0, theme::now().outline) + // a shadow only while it grows: settled, it lies on the panel, which has its own glow + .shadow(Shadow::drop("#000000").opacity(0.45 * grow * (1.0 - reveal)).blur(24.0).offset(0.0, 10.0)) + .align_child(Center, Start) + .translate(x, y) + .on_hover(|inside| { + Control::write().inside_menu = inside; + + if !inside { + collapse_soon(); + } + }) + .child(face); + + let region = InputArea { + x: (window_w - RIGHT - PANEL_W + x).floor() as i32, + y: (TOP_GAP + y).floor() as i32, + width: width.ceil() as i32, + height: height.ceil() as i32, + }; + + window.input_region(vec![region]).child( + Rectangle::new() + .width(Parent) + .height(Parent) + .padding(Padding { top: TOP_GAP, right: RIGHT, bottom: 0.0, left: 0.0 }) + .align_child(End, Start) + .child(Rectangle::new().width(PANEL_W).height(largest).align_child(Start, Start).child(shape)), + ) +} + +// back to the panel: the pointer is over it now, where the menu was +fn menu_back() { + close_menu(); + + Control::write().inside_panel = true; +} + +/* + * a menu's content and the tile or block it grows out of (x, y, width, height from the + * panel's top left). both windows call it, the panel for the height it takes; every + * Service is passed in, since a view reading one twice can deadlock against a writer + */ +fn menu_content(menu: Menu, control: &Control, links: &Links, bluetooth: &Bluetooth, sound: &Sound, extra_output: bool) -> (MenuContent, (f32, f32, f32, f32)) { + match menu { + Menu::Wifi => (wifi_menu(control, links), (PAD, PAD, TILE_W, TILE_H)), + Menu::Bluetooth => (bluetooth_menu(control, bluetooth, links), (PAD + TILE_W + GAP, PAD, TILE_W, TILE_H)), + Menu::Vpn => (vpn_menu(control, links), (PAD, PAD + TILE_H + GAP, TILE_W, TILE_H)), + Menu::Sound => { + let block = Layout { extra_output, ..Layout::LARGEST }.sound(); + + (sound_menu(control, sound), (PAD, PAD + 2.0 * (TILE_H + GAP), INNER_W, block)) + } + } +} + +// as tall as its content, at most as tall as the windows +fn menu_height(content: &MenuContent) -> f32 { + (2.0 * MENU_PAD + MENU_HEADER_H + GAP + content.height).min(Layout::LARGEST.height()) +} + +// what a menu shows under its header +struct MenuContent { + title: &'static str, + right: Option>, + height: f32, + body: Column, +} + +// a menu's rows, counting their height as they're added +struct Body { + rows: Vec>, + height: f32, + gap: f32, +} + +impl Body { + fn new(gap: f32) -> Self { + Self { rows: Vec::new(), height: 0.0, gap } + } + + fn add(&mut self, widget: impl Widget + 'static, height: f32) { + if !self.rows.is_empty() { + self.height += self.gap; + } + + self.rows.push(Box::new(widget)); + self.height += height; + } + + fn note(&mut self, text: &str) { + self.add(Rectangle::new().width(MENU_W).height(NOTE_H).padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 4.0 }).align_child(Start, Center).child(note(text)), NOTE_H); + } + + fn into_content(self, title: &'static str, right: Option>) -> MenuContent { + let gap = self.gap; + + MenuContent { title, right, height: self.height, body: Column::new(self.rows).gap(gap) } + } +} + +fn menu_header(control: &Control, title: &str, right: Option>) -> Row { + let theme = theme::now(); + + let lit = control.lit(Spot::Back); + + let back = Rectangle::new() + .width(MENU_HEADER_H) + .height(MENU_HEADER_H) + .radius(Full) + .fill(Color::blend(CHIP, CHIP_HOVER, lit)) + .align_child(Center, Center) + .cursor(Pointer) + .scale(1.0 + 0.06 * lit) + .on_hover(hovering(Spot::Back)) + .on_click(|_| menu_back()) + .child(icon(Glyph::Back, 13.0, theme.text)); + + let mut parts: Vec> = children![Row::new(children![back, Text::new(title).size(14.0).weight(Weight::Bold).color(theme.text)]).gap(10.0).align(Center)]; + + if let Some(right) = right { + parts.push(right); + } + + Row::new(parts).justify(SpaceBetween).align(Center).width(MENU_W).height(MENU_HEADER_H) +} + +// an on/off switch; grey: the service behind it isn't running, and switching on starts it +fn switch(control: &Control, spot: Spot, on: bool, grey: bool) -> Rectangle { + let theme = theme::now(); + + let lit = control.lit(spot); + + let track = if on { + Color::blend(theme.accent, theme.flame, 0.3 * lit) + } else if grey { + Color::blend(CHIP, CHIP_HOVER, lit) + } else { + Color::blend(CHIP_OPEN, CHIP_HOVER, lit) + }; + + let knob = if grey { faded(Color::WHITE, 140) } else { Color::WHITE }; + + Rectangle::new() + .width(42.0) + .height(24.0) + .radius(Full) + .fill(track) + .align_child(Start, Center) + .cursor(Pointer) + .on_hover(hovering(spot)) + .child(Rectangle::new().width(20.0).height(20.0).radius(Full).fill(knob).translate(if on { 20.0 } else { 2.0 }, 0.0)) +} + +// one line of a menu: a round icon, a name with a line under it, and something on the right +fn menu_row(control: &Control, index: usize, glyph: Glyph, active: bool, title: &str, subtitle: &str, right: Option>) -> Rectangle { + let theme = theme::now(); + + let lit = control.lit(Spot::Row(index)); + + let round = Rectangle::new() + .width(30.0) + .height(30.0) + .radius(Full) + .fill(if active { theme.accent } else { CHIP_OPEN }) + .align_child(Center, Center) + .child(icon(glyph, 15.0, if active { theme.on_accent } else { theme.text })); + + let right_w = if right.is_some() { 50.0 } else { 0.0 }; + + let words = Column::new(children![ + Text::new(title).size(12.0).weight(if active { Weight::Bold } else { Weight::Regular }).color(theme.text).elide(), + Text::new(subtitle).size(10.5).color(theme.dim).elide(), + ]) + .gap(3.0) + .width(MENU_W - 20.0 - 30.0 - 10.0 - right_w); + + let mut parts: Vec> = children![Row::new(children![round, words]).gap(10.0).align(Center)]; + + if let Some(right) = right { + parts.push(right); + } + + Rectangle::new() + .width(MENU_W) + .height(MENU_ROW_H) + .radius(12.0) + .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) + .align_child(Start, Center) + .translate(3.0 * lit, 0.0) + .on_hover(hovering(Spot::Row(index))) + .child(Row::new(parts).justify(SpaceBetween).align(Center).width(MENU_W - 20.0).height(MENU_ROW_H)) +} + +// a quiet text button at the bottom of a menu +fn footer(control: &Control, text: &str) -> Rectangle { + let lit = control.lit(Spot::Footer); + + Rectangle::new() + .width(MENU_W) + .height(30.0) + .radius(10.0) + .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .padding(Padding { top: 0.0, right: 0.0, bottom: 0.0, left: 10.0 }) + .align_child(Start, Center) + .cursor(Pointer) + .on_hover(hovering(Spot::Footer)) + .child(Text::new(text).size(11.5).color(Color::blend(theme::now().dim, theme::now().text, lit))) +} + +fn wifi_menu(control: &Control, links: &Links) -> MenuContent { + let wifi = &links.wifi; + + // the tile shows "One moment" until the switch has done its work + let before = wifi_state(links).subtitle; + + let right = (!wifi.service || !wifi.device.is_empty()).then(|| { + let toggle = switch(control, Spot::Switch, wifi.service && wifi.powered && !wifi.access_point, !wifi.service) + .on_click(move |_| act(Tile::Wifi, before.clone(), Action::Wifi)); + + Box::new(toggle) as Box + }); + + let mut body = Body::new(4.0); + + if !wifi.service { + body.note("iwd isn't running. The switch starts it."); + } else if wifi.device.is_empty() { + body.note("iwd sees no Wi-Fi device."); + } else if wifi.access_point { + body.note("The hotspot is using Wi-Fi; the switch stops it."); + } else if !wifi.powered { + body.note("Wi-Fi is off."); + } else if wifi.networks.is_empty() { + body.note("Looking for networks…"); + } else { + for (index, network) in wifi.networks.iter().take(MENU_ROWS).enumerate() { + let subtitle = if network.connected { + "Connected" + } else if network.known { + "Saved" + } else if network.secured { + "New · join it once with iwctl" + } else { + "Open" + }; + + let lock = network.secured.then(|| Box::new(icon(Glyph::Lock, 12.0, theme::now().dim)) as Box); + + let row = menu_row(control, index, Glyph::Wifi(network.bars), network.connected, &network.name, subtitle, lock); + + let joinable = network.known || !network.secured; + + let row = if network.connected { + row.cursor(Pointer).on_click(|_| later(system::disconnect_wifi, None)) + } else if joinable { + let path = network.path.clone(); + + row.cursor(Pointer).on_click(move |_| { + let path = path.clone(); + + later(move || system::connect_network(&path), None); + }) + } else { + row + }; + + body.add(row, MENU_ROW_H); + } + } + + if wifi.service { + body.add(footer(control, "Stop the Wi-Fi service").on_click(|_| later(system::stop_wifi_service, None)), 30.0); + } + + body.into_content("Wi-Fi", right) +} + +fn bluetooth_menu(control: &Control, bluetooth: &Bluetooth, links: &Links) -> MenuContent { + let before = bluetooth_state(bluetooth, links).subtitle; + + let right = (bluetooth.available() || links.bluetooth_service).then(|| { + let toggle = switch(control, Spot::Switch, bluetooth.available() && bluetooth.powered(), !bluetooth.available()) + .on_click(move |_| act(Tile::Bluetooth, before.clone(), Action::Bluetooth)); + + Box::new(toggle) as Box + }); + + let mut body = Body::new(4.0); + + if !bluetooth.available() && !links.bluetooth_service { + body.note("bluez isn't installed."); + body.note("emerge net-wireless/bluez, then switch it on here."); + } else if !bluetooth.available() { + body.note("bluetoothd isn't running. The switch starts it."); + } else if !bluetooth.powered() { + body.note("Bluetooth is off."); + } else if bluetooth.devices().is_empty() { + body.note(if bluetooth.scanning() { "Looking for devices…" } else { "No devices." }); + } else { + for (index, device) in bluetooth.devices().iter().take(MENU_ROWS).enumerate() { + let subtitle = match (device.connected(), device.paired(), device.battery()) { + (true, _, Some(battery)) => format!("Connected · {battery}%"), + (true, _, None) => String::from("Connected"), + (false, true, _) => String::from("Paired"), + (false, false, _) => String::from("Not paired · click to pair"), + }; + + let path = String::from(device.path()); + let (connected, paired) = (device.connected(), device.paired()); + + let row = menu_row(control, index, Glyph::Bluetooth, connected, device.name(), &subtitle, None).cursor(Pointer).on_click(move |_| { + if connected { + Bluetooth::disconnect(&path); + } else if paired { + Bluetooth::connect(&path); + } else { + Bluetooth::pair(&path); + } + }); + + body.add(row, MENU_ROW_H); + } + } + + body.into_content("Bluetooth", right) +} + +fn vpn_menu(control: &Control, links: &Links) -> MenuContent { + let before = vpn_state(links).subtitle; + + let mut body = Body::new(4.0); + + if links.vpns.is_empty() { + body.note("No openvpn service: /etc/init.d/openvpn.."); + } + + for (index, vpn) in links.vpns.iter().enumerate().take(MENU_ROWS) { + let (on, subtitle) = match vpn.state { + VpnState::Connected => (true, "Connected"), + VpnState::Connecting => (true, "Connecting…"), + VpnState::Stopped => (false, "Off"), + }; + + let toggle = switch(control, Spot::Row(index), on, false); + + let service = vpn.service.clone(); + let before = before.clone(); + + let row = menu_row(control, index, Glyph::Lock, vpn.state == VpnState::Connected, vpn.name(), subtitle, Some(Box::new(toggle))) + .cursor(Pointer) + .on_click(move |_| act(Tile::Vpn, before.clone(), Action::Vpn(service.clone()))); + + body.add(row, MENU_ROW_H); + } + + body.into_content("VPN", None) +} + +// every output with its own volume; a click on its name makes it the default +fn sound_menu(control: &Control, sound: &Sound) -> MenuContent { + let theme = theme::now(); + + let mut body = Body::new(10.0); + + if sound.outputs().is_empty() { + body.note("No outputs."); + } + + for (index, sink) in sound.outputs().iter().take(OUTPUTS).enumerate() { + let default = sink.name == sound.default_output(); + let lit = control.lit(Spot::Row(index)); + + let radio = Rectangle::new().width(12.0).height(12.0).radius(Full); + let radio = if default { radio.fill(theme.accent) } else { radio.border(1.5, theme.dim) }; + + let title = parse::middle_ellipsis(parse::short_sink_name(&sink.description), 38); + + let name = Rectangle::new() + .width(MENU_W - 70.0) + .height(OUTPUT_H) + .radius(8.0) + .fill(Color::blend(CLEAR, CHIP_HOVER, lit)) + .padding(Padding { top: 0.0, right: 6.0, bottom: 0.0, left: 6.0 }) + .align_child(Start, Center) + .cursor(Pointer) + .on_hover(hovering(Spot::Row(index))) + .on_click({ + let name = sink.name.clone(); + + move |_| Sound::set_default_output(&name) + }) + .child( + Row::new(children![radio, Text::new(title).size(11.5).weight(if default { Weight::Bold } else { Weight::Regular }).color(theme.text).elide()]) + .gap(8.0) + .align(Center), + ); + + let line = Row::new(children![name, output_mute(control, sink)]).justify(SpaceBetween).align(Center).width(MENU_W).height(OUTPUT_H); + + body.add(Column::new(children![line, output_slider(control, sink, MENU_W)]).gap(6.0), OUTPUT_H + 6.0 + SLIDER_H); + } + + body.into_content("Sound", None) +} diff --git a/src/glyph.rs b/src/glyph.rs index 87a8b04..47dfba9 100644 --- a/src/glyph.rs +++ b/src/glyph.rs @@ -12,6 +12,8 @@ pub enum Glyph { Muted, Bluetooth, Chevron, + // pointing left, for a menu's way back + Back, Note, // wi-fi with 0 to 3 of its arcs lit; the unlit ones are drawn faint @@ -91,6 +93,9 @@ pub fn bold_icon(glyph: Glyph, size: f32, color: Color, weight: f32) -> Canvas { Glyph::Chevron => shapes![ Path::new().move_to(0.12 * s, 0.32 * s).line_to(0.5 * s, 0.7 * s).line_to(0.88 * s, 0.32 * s).stroke(1.5, color).cap(Cap::Round) ], + Glyph::Back => shapes![ + Path::new().move_to(0.64 * s, 0.14 * s).line_to(0.3 * s, 0.5 * s).line_to(0.64 * s, 0.86 * s).stroke(thin.max(1.6), color).cap(Cap::Round) + ], // an eighth note: round head, stem, a curved flag Glyph::Note => shapes![ Circle::new().center(0.32 * s, 0.74 * s).radius(0.17 * s).fill(color), diff --git a/src/island.rs b/src/island.rs index fc0fdf9..5571440 100644 --- a/src/island.rs +++ b/src/island.rs @@ -23,8 +23,8 @@ use crate::theme; * on the settled shape. closing runs the same timeline backwards */ const OPEN_MS: u64 = 560; -const PILL_GONE: f32 = 0.18; -const SHAPE_DONE: f32 = 0.6; +pub const PILL_GONE: f32 = 0.18; +pub const SHAPE_DONE: f32 = 0.6; // hover highlights ease in and out over this long; the pill widens by PILL_HOVER_GROW pub const HOVER_MS: u64 = 220; diff --git a/src/main.rs b/src/main.rs index 5a75ee9..382b709 100644 --- a/src/main.rs +++ b/src/main.rs @@ -24,5 +24,6 @@ fn main() { .window_per_monitor(island::view) .window_per_monitor(workspaces::view) .window_per_monitor(control::view) + .window_per_monitor(control::menu_view) .run(); } diff --git a/src/parse.rs b/src/parse.rs index 1a65f4f..f8572bf 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -37,6 +37,10 @@ pub struct Sink { pub name: String, pub description: String, pub bluetooth: bool, + + // first channel, in percent + pub volume: u8, + pub muted: bool, } pub fn sink_inputs(text: &str) -> Vec { @@ -97,6 +101,10 @@ pub fn sinks(text: &str) -> Vec { sink.name = name.to_string(); } else if let Some(description) = line.strip_prefix("Description: ") { sink.description = description.to_string(); + } else if let Some(mute) = line.strip_prefix("Mute: ") { + sink.muted = mute == "yes"; + } else if let Some(volume) = line.strip_prefix("Volume: ") { + sink.volume = first_percent(volume); } else if line == "device.api = \"bluez5\"" { sink.bluetooth = true; } @@ -105,6 +113,17 @@ pub fn sinks(text: &str) -> Vec { sinks } +/* + * what a person calls an output: the monitor or receiver an hdmi port names in brackets, + * "... DisplayPort 1 Output [TX-NR515] (Stereo)" gives "TX-NR515", else the description + */ +pub fn short_sink_name(description: &str) -> &str { + match (description.find('['), description.find(']')) { + (Some(open), Some(close)) if open < close => &description[open + 1..close], + _ => description, + } +} + // "front-left: 52429 / 80% / -5.81 dB, front-right: ..." gives 80 fn first_percent(volume: &str) -> u8 { let percent = volume.split('/').nth(1).map(|part| part.trim().trim_end_matches('%')); @@ -462,6 +481,9 @@ Sink Input #57 \tState: RUNNING \tName: alsa_output.usb-Yamaha_AG03MK2-00.analog-stereo \tDescription: Yamaha AG03MK2 Analog Stereo +\tMute: no +\tVolume: front-left: 52428 / 80% / -5.81 dB, front-right: 52428 / 80% / -5.81 dB +\tBase Volume: 65536 / 100% / 0.00 dB \tProperties: \t\tdevice.api = \"alsa\" \tPorts: @@ -470,6 +492,7 @@ Sink Input #57 Sink #80 \tName: bluez_output.00_1B_66_AA_BB_CC.1 \tDescription: JBL Tune 720BT +\tMute: yes \tProperties: \t\tdevice.api = \"bluez5\" "; @@ -492,7 +515,11 @@ Sink #80 assert_eq!(sinks.len(), 2); assert_eq!((sinks[0].index, sinks[0].bluetooth), (65, false)); assert_eq!(sinks[0].description, "Yamaha AG03MK2 Analog Stereo"); + assert_eq!((sinks[0].volume, sinks[0].muted, sinks[1].muted), (80, false, true)); assert_eq!((sinks[1].name.as_str(), sinks[1].description.as_str(), sinks[1].bluetooth), ("bluez_output.00_1B_66_AA_BB_CC.1", "JBL Tune 720BT", true)); + + assert_eq!(short_sink_name("700 Series Chipset Family HD Audio HDMI / DisplayPort 1 Output [TX-NR515] (Stereo)"), "TX-NR515"); + assert_eq!(short_sink_name("Yamaha AG03MK2 Analog Stereo"), "Yamaha AG03MK2 Analog Stereo"); } #[test] diff --git a/src/sound.rs b/src/sound.rs index 1db802c..8a1818b 100644 --- a/src/sound.rs +++ b/src/sound.rs @@ -69,6 +69,9 @@ pub struct Sound { outputs: Vec, channels: Vec, + // the sink new sound goes to (`pactl get-default-sink`) + default_output: String, + // by player identity, so "Cider" keeps its curve across restarts eqs: BTreeMap, unsaved: bool, @@ -108,6 +111,63 @@ impl Sound { &self.outputs } + pub fn default_output(&self) -> &str { + &self.default_output + } + + // the first output whose pactl name or description contains `wanted`; none for an empty `wanted` + pub fn output_named(&self, wanted: &str) -> Option<&Sink> { + let wanted = wanted.to_lowercase(); + + (!wanted.is_empty()) + .then(|| self.outputs.iter().find(|sink| sink.description.to_lowercase().contains(&wanted) || sink.name.to_lowercase().contains(&wanted))) + .flatten() + } + + // a whole output's volume, unmuting it, as the control centre's sliders set it + pub fn set_output_volume(name: &str, volume: u8) { + let mut sound = Self::write(); + + sound.hold(); + + let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else { + return; + }; + + if sink.muted { + run(&["pactl", "set-sink-mute", name, "0"]); + } + + run(&["pactl", "set-sink-volume", name, &format!("{volume}%")]); + + sink.volume = volume; + sink.muted = false; + } + + pub fn toggle_output_mute(name: &str) { + let mut sound = Self::write(); + + sound.hold(); + + let Some(sink) = sound.outputs.iter_mut().find(|sink| sink.name == name) else { + return; + }; + + sink.muted = !sink.muted; + + run(&["pactl", "set-sink-mute", name, if sink.muted { "1" } else { "0" }]); + } + + // where new sound goes; players without an output of their own follow it + pub fn set_default_output(name: &str) { + let mut sound = Self::write(); + + sound.hold(); + sound.default_output = name.to_string(); + + run(&["pactl", "set-default-sink", name]); + } + pub fn channel(&self, player: &str) -> Option<&Channel> { self.channels.iter().find(|channel| channel.player == player) } @@ -351,10 +411,12 @@ fn poll(known: &mut BTreeSet) { let outputs: Vec = sinks.into_iter().filter(|sink| !sink.name.starts_with(EQ_PREFIX)).collect(); + let default_output = amane::output("pactl get-default-sink"); + let stale = { let sound = Sound::read(); - !sound.held() && (sound.channels != channels || sound.outputs != outputs) + !sound.held() && (sound.channels != channels || sound.outputs != outputs || sound.default_output != default_output) }; if stale { @@ -362,6 +424,7 @@ fn poll(known: &mut BTreeSet) { sound.channels = channels; sound.outputs = outputs; + sound.default_output = default_output; } } diff --git a/src/system.rs b/src/system.rs index 9359a96..e063f3a 100644 --- a/src/system.rs +++ b/src/system.rs @@ -46,6 +46,20 @@ pub struct Wifi { pub hotspot: bool, pub hotspot_name: String, pub clients: usize, + + // the networks in range, the connected one first, then by signal + pub networks: Vec, +} + +#[derive(Clone, PartialEq)] +pub struct Network { + // iwd's object path, which Connect is called on + pub path: String, + pub name: String, + pub secured: bool, + pub known: bool, + pub connected: bool, + pub bars: u8, } #[derive(Clone, Copy, PartialEq)] @@ -136,21 +150,32 @@ fn wifi() -> Wifi { let connected = station.get("ConnectedNetwork").text(); let network = objects.get(connected).map_or("", |network| network.get("net.connman.iwd.Network").get("Name").text()); - // the connected network's signal, in hundredths of a dBm - let bars = if connected.is_empty() { - 0 - } else { - let ordered = bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]); + // every network in range as [path, signal in hundredths of a dBm], strongest first + let ordered = if matches!(station, Value::Map(_)) { bus.call(IWD, path, STATION, "GetOrderedNetworks", &[]) } else { Value::Nothing }; - let signal = ordered - .list() - .iter() - .find(|entry| entry.list().first().map(|path| path.text()) == Some(connected)) - .and_then(|entry| entry.list().get(1)) - .map_or(-10_000.0, |signal| signal.number()); + let mut networks: Vec = ordered + .list() + .iter() + .filter_map(|entry| { + let path = entry.list().first()?.text(); + let found = objects.get(path)?.get("net.connman.iwd.Network"); + let signal = entry.list().get(1).map_or(-10_000.0, |signal| signal.number()); - parse::wifi_bars(signal / 100.0) - }; + Some(Network { + path: path.to_string(), + name: found.get("Name").text().to_string(), + secured: found.get("Type").text() != "open", + known: !found.get("KnownNetwork").text().is_empty(), + connected: path == connected, + bars: parse::wifi_bars(signal / 100.0), + }) + }) + .collect(); + + // a stable sort keeps the signal order within each group + networks.sort_by_key(|network| !network.connected); + + let bars = networks.iter().find(|network| network.connected).map_or(0, |network| network.bars); let hotspot = access_point.get("Started").bool(); @@ -171,6 +196,7 @@ fn wifi() -> Wifi { hotspot, hotspot_name: access_point.get("Name").text().to_string(), clients, + networks, } } @@ -302,6 +328,33 @@ fn stop_hotspot(device: &str) { bus.set_property(IWD, device, DEVICE, "Mode", Argument::from("station")); } +// the Wi-Fi menu: joins a known or open network (a new secured one needs a password, which the menu can't ask for yet) +pub fn connect_network(path: &str) { + Bus::system().call(IWD, path, "net.connman.iwd.Network", "Connect", &[]); +} + +pub fn disconnect_wifi() { + let device = Links::read().wifi.device.clone(); + + if !device.is_empty() { + Bus::system().call(IWD, &device, STATION, "Disconnect", &[]); + } +} + +// a fresh list for the open Wi-Fi menu; iwd answers once the scan is done +pub fn scan_wifi() { + let wifi = Links::read().wifi.clone(); + + if wifi.service && wifi.powered && !wifi.access_point && !wifi.device.is_empty() { + Bus::system().call(IWD, &wifi.device, STATION, "Scan", &[]); + } +} + +// back to how the desktop runs without Wi-Fi: iwd not running at all +pub fn stop_wifi_service() { + root(&["rc-service", "iwd", "stop"]); +} + pub fn toggle_vpn(service: &str) { if !parse::safe_name(service) { return; @@ -460,19 +513,49 @@ pub fn set_profile(profile: &str) { refresh_power(); } -// logind writes the root-owned file for whoever sits at the machine, as amane's Brightness does +/* + * the light's file directly when the user may write it (a udev rule makes it 0666 on the + * desktop), else logind, which only does it for a session with a seat: a shell started + * over ssh has none, which is why logind alone failed + */ pub fn set_keyboard(level: u32) { let Some(light) = Power::read().keyboard.clone() else { return; }; - let arguments = [Argument::from("leds"), Argument::from(light.name.as_str()), Argument::from(level.min(light.max))]; + let level = level.min(light.max); - Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments); + let file = Path::new("/sys/class/leds").join(&light.name).join("brightness"); + + if fs::write(&file, level.to_string()).is_err() { + logind_brightness("leds", &light.name, level); + } refresh_power(); } +// the laptop screen the same way, at 1% or more; amane's Brightness reads it back within half a second +pub fn set_screen(percent: u8) { + let Some(folder) = fs::read_dir("/sys/class/backlight").ok().and_then(|mut entries| entries.next()).and_then(Result::ok).map(|entry| entry.path()) else { + return; + }; + + let highest: u64 = read(&folder.join("max_brightness")).parse().unwrap_or(0); + let level = highest * u64::from(percent.clamp(1, 100)) / 100; + + if fs::write(folder.join("brightness"), level.to_string()).is_err() { + let name = folder.file_name().map(|name| name.to_string_lossy().into_owned()).unwrap_or_default(); + + logind_brightness("backlight", &name, level as u32); + } +} + +fn logind_brightness(subsystem: &str, name: &str, level: u32) { + let arguments = [Argument::from(subsystem), Argument::from(name), Argument::from(level)]; + + Bus::system().call("org.freedesktop.login1", "/org/freedesktop/login1/session/auto", "org.freedesktop.login1.Session", "SetBrightness", &arguments); +} + // the external gpu #[derive(Clone, PartialEq)] diff --git a/src/theme.rs b/src/theme.rs index 1e03bfe..dd4582a 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -76,6 +76,9 @@ pub struct Theme { // the iwd access point profile the control centre's hotspot starts (/var/lib/iwd/ap/.ap); empty: none pub hotspot: String, + + // a second Sound slider in the control centre, for the output whose name contains this; empty: none + pub extra_output: String, } impl Service for Theme { @@ -84,6 +87,7 @@ impl Service for Theme { colors: Animation::new(PINK_ON_PURPLE), cover_colors: false, hotspot: String::new(), + extra_output: String::new(), } } @@ -179,6 +183,7 @@ fn apply(theme: &str, config: &str, startup: bool) { theme.cover_colors = setting("cover_colors") == Some("true"); theme.hotspot = setting("hotspot").unwrap_or("").to_string(); + theme.extra_output = setting("extra_output").unwrap_or("").to_string(); // a change in the middle of a fade starts from the colours on screen now let now = theme.colors.value();