diff --git a/crates/beaver_hal/src/brightness/linux.rs b/crates/beaver_hal/src/brightness/linux.rs index fa7ffc4..97c812e 100644 --- a/crates/beaver_hal/src/brightness/linux.rs +++ b/crates/beaver_hal/src/brightness/linux.rs @@ -40,7 +40,7 @@ impl BrightnessDevice for Device { .method_call( "org.freedesktop.login1.Session", "SetBrightness", - (self.kind.to_string(), self.name.as_str(), value), + (self.kind.subsystem(), self.name.as_str(), value), ) .map_err(Box::new)?) } diff --git a/crates/beaver_hal/src/brightness/mod.rs b/crates/beaver_hal/src/brightness/mod.rs index af8156f..2d73ff8 100644 --- a/crates/beaver_hal/src/brightness/mod.rs +++ b/crates/beaver_hal/src/brightness/mod.rs @@ -8,16 +8,30 @@ pub enum DeviceKind { Backlight, } +/// Renders the singular device kind, e.g. "led" - a `DeviceKind` describes one +/// device. For the logind subsystem name (plural "leds"), which is what +/// `Session.SetBrightness` expects on the wire, use [`DeviceKind::subsystem`]. impl Display for DeviceKind { fn fmt(&self, fmt: &mut std::fmt::Formatter<'_>) -> std::result::Result<(), Error> { match self { - DeviceKind::Led => fmt.write_str("leds"), + DeviceKind::Led => fmt.write_str("led"), DeviceKind::Backlight => fmt.write_str("backlight"), }?; Ok(()) } } +impl DeviceKind { + /// The logind/sysfs subsystem this kind lives under ("leds", "backlight"). + /// Note the plural for LEDs: this is the wire name, not the device kind. + pub fn subsystem(&self) -> &'static str { + match self { + DeviceKind::Led => "leds", + DeviceKind::Backlight => "backlight", + } + } +} + impl Copy for DeviceKind {} /// Representation of a device for which the brightness can be controlled. diff --git a/patches/components/script/dom/brightnessdevice.rs.patch b/patches/components/script/dom/brightnessdevice.rs.patch index d3c6cef..3b69775 100644 --- a/patches/components/script/dom/brightnessdevice.rs.patch +++ b/patches/components/script/dom/brightnessdevice.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,126 @@ +@@ -0,0 +1,133 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! A single brightness-controllable device (screen backlight, keyboard LED), @@ -17,7 +17,9 @@ +use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; +use servo_constellation_traits::{BrightnessDeviceInfo, ScriptToConstellationMessage}; + -+use crate::dom::bindings::codegen::Bindings::PowerBinding::BrightnessDeviceMethods; ++use crate::dom::bindings::codegen::Bindings::PowerBinding::{ ++ BrightnessDeviceKind, BrightnessDeviceMethods, ++}; +use crate::dom::bindings::reflector::DomGlobal; +use crate::dom::bindings::root::DomRoot; +use crate::dom::globalscope::GlobalScope; @@ -30,7 +32,7 @@ + /// Index of this device in the constellation's device list. + id: u32, + name: String, -+ kind: String, ++ kind: BrightnessDeviceKind, + is_on_off: bool, + /// Last known brightness in percent: refreshed when the device is enumerated + /// and updated optimistically on write, so reads never block. @@ -43,7 +45,12 @@ + _reflector: Reflector::new(), + id: info.id, + name: info.name, -+ kind: info.kind, ++ // The HAL is the only producer of this string; anything unexpected ++ // is treated as an LED rather than claiming to be the screen. ++ kind: match info.kind.as_str() { ++ "backlight" => BrightnessDeviceKind::Backlight, ++ _ => BrightnessDeviceKind::Led, ++ }, + is_on_off: info.is_on_off, + percent: Cell::new(info.percent), + } @@ -102,8 +109,8 @@ + DOMString::from(self.name.clone()) + } + -+ fn Kind(&self) -> DOMString { -+ DOMString::from(self.kind.clone()) ++ fn Kind(&self) -> BrightnessDeviceKind { ++ self.kind + } + + fn IsOnOff(&self) -> bool { diff --git a/patches/components/script_bindings/webidls/Power.webidl.patch b/patches/components/script_bindings/webidls/Power.webidl.patch index abe0d3b..a9d0df0 100644 --- a/patches/components/script_bindings/webidls/Power.webidl.patch +++ b/patches/components/script_bindings/webidls/Power.webidl.patch @@ -1,10 +1,13 @@ --- original +++ modified -@@ -0,0 +1,38 @@ +@@ -0,0 +1,40 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +// Control of the device's brightness-capable hardware (screen backlight, keyboard LEDs). + ++// The kind of hardware a brightness device drives. ++enum BrightnessDeviceKind { "backlight", "led" }; ++ +[Exposed=Window, +Func="Embedder::is_allowed_to_embed"] +interface BrightnessDevice { @@ -20,8 +23,7 @@ + // The platform name of the device, e.g. "intel_backlight". + readonly attribute DOMString name; + -+ // "backlight" or "led". -+ readonly attribute DOMString kind; ++ readonly attribute BrightnessDeviceKind kind; + + // True when the device only supports 0% and 100%. + readonly attribute boolean isOnOff; diff --git a/ui/shared/dbus/network_manager.js b/ui/shared/dbus/network_manager.js index 004b7e0..dd403dd 100644 --- a/ui/shared/dbus/network_manager.js +++ b/ui/shared/dbus/network_manager.js @@ -549,6 +549,16 @@ export class WifiDevice extends EventTarget { return this._activePath ? this._aps.get(this._activePath) || null : null; } + /** + * Resolve and return the AP we are associated with, without starting + * monitoring. For compact surfaces (a quick-settings tile) that want the + * current SSID but must not pay for a GetAll per visible network. + */ + async currentAccessPoint() { + await this._resolveActive(); + return this.activeAccessPoint; + } + /** * What a picker renders: strongest AP per SSID, excluding the active one and * hidden (empty-SSID) networks, strongest first. Recompute on diff --git a/ui/shared/power/brightness.js b/ui/shared/power/brightness.js index 79a4aa7..04a4042 100644 --- a/ui/shared/power/brightness.js +++ b/ui/shared/power/brightness.js @@ -1,6 +1,6 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -// Screen brightness, on top of `navigator.embedder.power` +// Screen brightness, on top of `navigator.embedder.power` // The chrome controls the first available backlight, so this // module resolves that device once and exposes a small surface the UI can bind to. @@ -69,3 +69,72 @@ class ScreenBrightness { } export const screenBrightness = new ScreenBrightness(); + +// Names that suggest a camera flash / torch LED rather than a notification, +// charging or keyboard-backlight LED. Matched case-insensitively against the +// sysfs node name (e.g. "white:flash", "torch-light0"). +const TORCH_HINTS = ["torch", "flash"]; + +/** + * The camera torch, when the device exposes one. + * + * LEDs come from the same `power.devices()` list as the backlight, under + * `kind === "led"`. Several unrelated LEDs live there - charging, notification, + * keyboard backlight - so a name hint picks the flash node, and we deliberately + * expose nothing when none matches: silently toggling a charging indicator + * would be worse than having no torch button. + * + * Level count is not used to identify one: real torches range from a plain + * on/off node to 255 levels. Toggling drives it to full or off. + */ +class Torch { + #ready = null; + + /** Resolve (once) the torch LED, or null when the device has none. */ + async device() { + if (!this.#ready) { + this.#ready = this.#resolve(); + } + return this.#ready; + } + + async #resolve() { + const api = powerApi(); + if (!api) { + return null; + } + try { + const devices = await api.devices(); + // The name is what identifies a torch; `isOnOff` is deliberately NOT + // required, because plenty of flash LEDs expose several levels rather + // than a plain on/off (max_brightness > 1). Toggling drives it to full. + return ( + devices.find((device) => { + if (device.kind !== "led") { + return false; + } + const name = device.name.toLowerCase(); + return TORCH_HINTS.some((hint) => name.includes(hint)); + }) ?? null + ); + } catch (error) { + console.warn("[torch] Failed to list power devices:", error); + return null; + } + } + + /** True when the torch is currently lit, or false when there is none. */ + async isOn() { + const device = await this.device(); + return device ? device.brightness > 0 : false; + } + + async set(on) { + const device = await this.device(); + if (device) { + device.brightness = on ? 100 : 0; + } + } +} + +export const torch = new Torch(); diff --git a/ui/shared/power/brightness_slider.js b/ui/shared/power/brightness_slider.js index c65f8e1..33eef6d 100644 --- a/ui/shared/power/brightness_slider.js +++ b/ui/shared/power/brightness_slider.js @@ -46,19 +46,34 @@ export class BrightnessSlider extends LitElement { .glyph { flex: none; + display: flex; + align-items: center; color: var(--color-text-tertiary, #999); - font-size: var(--font-size-sm, 13px); - line-height: 1; + font-size: var(--font-size-lg, 17px); } + /* The track is visually thin but the touch target must not be: the row is + padded out to a comfortable finger height and the whole of it is + draggable, with the thin bar drawn centred inside it. */ .track { position: relative; flex: 1; + height: 40px; + cursor: pointer; + touch-action: none; + } + + /* The visible bar, centred in the tall hit area. */ + .bar { + position: absolute; + top: 50%; + left: 0; + right: 0; height: 6px; + transform: translateY(-50%); border-radius: 3px; background: var(--color-border, #444); - cursor: pointer; - touch-action: none; + overflow: hidden; } .fill { @@ -67,11 +82,25 @@ export class BrightnessSlider extends LitElement { background: var(--color-primary, #4a8); } + /* The thumb travels along a rail inset by half its width at each end, so + it stays fully inside the track instead of overhanging (at 100% it used + to collide with the value label). Doing this in CSS rather than from a + measured width keeps it correct on the very first paint, before any + layout has been read. Inset must be half of .thumb's size. */ + .thumb-rail { + position: absolute; + top: 0; + bottom: 0; + left: 9px; + right: 9px; + pointer-events: none; + } + .thumb { position: absolute; top: 50%; - width: 14px; - height: 14px; + width: 18px; + height: 18px; border-radius: 50%; background: var(--color-primary, #4a8); box-shadow: 0 1px 3px rgb(0 0 0 / 0.4); @@ -200,7 +229,9 @@ export class BrightnessSlider extends LitElement { render() { return html`
- +
-
-
+
+
+
+
+
+
${this.percent}%
diff --git a/ui/system/mobile/pebble.css b/ui/system/mobile/pebble.css index 653567a..5f559bf 100644 --- a/ui/system/mobile/pebble.css +++ b/ui/system/mobile/pebble.css @@ -16,6 +16,10 @@ --pebble-morph-duration: 0.42s; --pebble-side-margin: var(--spacing-xl); --pebble-drag-x: 0px; + /* How far the pebble is lifted to make room for the quick-settings tray + hanging beneath it. Vertical analogue of --pebble-drag-x; updated live + during the pull-down drag. */ + --pebble-tray-lift: 0px; /* Touch target for the drag handle at the top of the glance / command card. Tall enough to hit easily with a real finger; the visible bar inside is small (see .handle::before). */ @@ -66,7 +70,9 @@ .pebble { position: absolute; left: 50%; - bottom: calc(24px + var(--keyboard-offset, 0px)); + bottom: calc( + 24px + var(--keyboard-offset, 0px) + var(--pebble-tray-lift, 0px) + ); width: 92px; height: 44px; border-radius: 22px 24px 22px 23px; @@ -179,6 +185,49 @@ transition: transform 0s; } +/* ----- Quick-settings tray ----- + A sibling of `.pebble` (which is overflow:hidden and would clip it). Its + bottom edge keeps the pebble's original 24px margin, so the controls sit in + the lowest, most thumb-reachable band while the pebble rides above them on + --pebble-tray-lift. Hidden until a pull-down starts. */ + +pebble-tray { + position: absolute; + left: var(--pebble-side-margin); + right: var(--pebble-side-margin); + /* Rests at the pebble's own bottom margin. The gap between the two comes + from the pebble lifting by card height + gap, not from moving the tray up + as well (that would cancel out and leave them touching). */ + bottom: calc(24px + var(--keyboard-offset, 0px)); + visibility: hidden; + pointer-events: none; + /* Slid down out of view and revealed by translating up as the pull grows. + Deliberately NOT clipped with overflow/max-height: that would cut the + card's soft shadow off square and read as a hard border. + --pebble-tray-reveal runs 0 (hidden) -> tray height (fully out). */ + transform: translateY( + calc(var(--pebble-tray-height, 0px) - var(--pebble-tray-reveal, 0px)) + ); + opacity: 0; + transition: + transform var(--pebble-morph-duration) var(--pebble-easing), + opacity var(--pebble-morph-duration) var(--pebble-easing); +} + +pebble-tray.visible { + visibility: visible; + pointer-events: auto; + opacity: 1; +} + +/* While the finger is down the tray tracks it 1:1, so the height transition + would only lag behind; it's re-enabled on release so the settle animates. + Same trick as `[data-dragging] .pebble` above. */ +:host([data-tray-dragging]) pebble-tray, +:host([data-tray-dragging]) .pebble { + transition: none; +} + .pebble-content { position: absolute; inset: 0; @@ -810,19 +859,13 @@ color: var(--color-text); } -/* ----- Command sheet cards ----- */ +/* ----- Content blocker section ----- */ -.brightness-card, .blocker-card { background: var(--bg-surface); border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 12px 14px; -} - -/* ----- Content blocker section ----- */ - -.blocker-card { display: flex; flex-direction: column; gap: var(--spacing-md); @@ -897,7 +940,8 @@ @media (prefers-reduced-motion: reduce) { .pebble, - .pebble-content { + .pebble-content, + pebble-tray { transition-duration: 0.01s !important; animation: none !important; } diff --git a/ui/system/mobile/pebble.js b/ui/system/mobile/pebble.js index d0465f6..f01c4d9 100644 --- a/ui/system/mobile/pebble.js +++ b/ui/system/mobile/pebble.js @@ -9,9 +9,9 @@ import { } from "beaver://shared/third_party/lit/lit-all.min.js"; import { SearchController } from "beaver://shared/search/controller.js"; -import { screenBrightness } from "beaver://shared/power/brightness.js"; import "beaver://shared/power/brightness_slider.js"; import "./pebble_radial.js"; +import "./pebble_tray.js"; import "./pebble_peek.js"; import "./pebble_results.js"; @@ -25,6 +25,15 @@ const T_COMMAND = 80; const T_TAB_COMMIT = 40; const T_LONGPRESS_MS = 480; const T_DOCK_HMOVE = 18; +// Pull-down tray: distance past which a release commits to open, and how far +// the drag can run past the tray's height before it stops following 1:1. +const T_TRAY_COMMIT = 24; +const TRAY_RUBBER_BAND = 0.35; +// Breathing room between the bottom of the pebble and the top of the tray, so +// the two read as separate objects rather than one welded shape. The pebble +// lifts by the card's height plus this; the card itself rests at the pebble's +// normal bottom margin, and the difference is the visible gap. +const TRAY_GAP = 10; // Radial menu: zone set used on a regular webview const RADIAL_ZONES_PAGE = [ @@ -77,8 +86,6 @@ export class MobilePebble extends LitElement { blockerEnabled: { type: Boolean, state: true }, blockerCount: { type: Number, state: true }, blockerAllowed: { type: Number, state: true }, - // Screen brightness (hidden when the device has no controllable backlight) - hasBrightness: { type: Boolean, state: true }, // Search results results: { type: Array, state: true }, groups: { type: Array, state: true }, @@ -91,6 +98,17 @@ export class MobilePebble extends LitElement { peekDirection: { type: Number, state: true }, // Inline pebble offset during dock-drag (px from current dock anchor) dragOffset: { type: Number, state: true }, + // Quick-settings tray: open state, and its live height during the pull-down + // drag. Orthogonal to STATE (like data-searching), not a fourth rung. + trayOpen: { type: Boolean, reflect: true, attribute: "data-tray" }, + trayDrag: { type: Number, state: true }, + // True only while the finger is down on a tray pull: suppresses the + // height/position transitions so the tray tracks the thumb exactly. + trayDragging: { + type: Boolean, + reflect: true, + attribute: "data-tray-dragging", + }, }; static styles = css` @@ -119,7 +137,11 @@ export class MobilePebble extends LitElement { this.blockerEnabled = true; this.blockerCount = 0; this.blockerAllowed = 0; - this.hasBrightness = false; + this.trayOpen = false; + this.trayDrag = 0; + this.trayDragging = false; + // Measured from the tray element the first time it is pulled. + this.trayHeight = 0; this.results = []; this.groups = []; @@ -161,10 +183,6 @@ export class MobilePebble extends LitElement { this._boundViewScrollStart, ); document.addEventListener("webview-scroll-end", this._boundViewScrollEnd); - // `device()` resolves to null rather than rejecting when there's no backlight. - screenBrightness.device().then((device) => { - this.hasBrightness = !!device; - }); } disconnectedCallback() { @@ -444,7 +462,8 @@ export class MobilePebble extends LitElement { this.state = next; if (next !== STATE.AMBIENT) { this.updateState(); - } else { + } + if (next === STATE.AMBIENT) { this.results = []; this.groups = []; this.searching = false; @@ -462,7 +481,7 @@ export class MobilePebble extends LitElement { return !!el.closest( "button, input, a, .tab-chip, .nav-link, .tab-card, .notif-item, " + "pebble-results, .blocker-toggle, .search-submit, label, media-control, " + - "brightness-slider", + "brightness-slider, pebble-tray", ); } @@ -497,6 +516,7 @@ export class MobilePebble extends LitElement { didLongPress: false, isDocking: false, isTabSwitching: false, + isTraying: false, peekDirection: 0, longPressTimer: null, }; @@ -557,6 +577,32 @@ export class MobilePebble extends LitElement { return; } + // Vertical pull down in ambient → quick-settings tray, tracking the finger. + // Only downward, and only from ambient: down from glance/command already + // means "collapse". Skipped when there is no hardware to show, and when the + // tray is already out (a second pull-down closes it, handled on release, so + // there is nothing to track here). + if ( + this._g.axis === "y" && + this._g.startState === STATE.AMBIENT && + !this._g.didLongPress && + !this.trayOpen && + dy > 0 + ) { + if (!this._g.isTraying) { + if (!this._trayHasContent()) { + return; + } + this._g.isTraying = true; + this.trayDragging = true; + this._measureTray(); + } + const max = this.trayHeight; + // Follow 1:1 up to the tray's height, then resist rather than overshoot. + this.trayDrag = dy <= max ? dy : max + (dy - max) * TRAY_RUBBER_BAND; + return; + } + // Horizontal pan in ambient → tab switch peek. Once a long-press has // fired, the gesture is committed to the radial / dock-drag flow, so // never start a peek from here. @@ -619,10 +665,28 @@ export class MobilePebble extends LitElement { return; } + // Tray pull-down finalize: commit past the threshold, else spring back. + if (this._g.isTraying) { + this.trayDragging = false; + if (dy > T_TRAY_COMMIT) { + this._openTray(); + } else { + this._closeTray(); + } + this._g = null; + return; + } + // Vertical pan release if (this._g.axis === "y") { if (this._g.startState === STATE.AMBIENT) { - if (dy < -T_COMMAND) this._setState(STATE.COMMAND); + // With the tray open the gesture acts on the tray, not the ladder: + // it's what's on screen. Either direction puts it away - down because + // the tray hangs below the pebble, up because that's the reverse of + // the pull that opened it. + if (this.trayOpen) { + if (Math.abs(dy) > T_TRAY_COMMIT) this._closeTray(); + } else if (dy < -T_COMMAND) this._setState(STATE.COMMAND); else if (dy < -T_GLANCE) this._setState(STATE.GLANCE); } else if (this._g.startState === STATE.GLANCE) { if (dy < -T_COMMAND) this._setState(STATE.COMMAND); @@ -637,7 +701,8 @@ export class MobilePebble extends LitElement { // Tap (no pan, no long-press) if (!this._g.didPan && !this._g.didLongPress) { - if (this.state === STATE.AMBIENT) this._setState(STATE.GLANCE); + if (this.trayOpen) this._closeTray(); + else if (this.state === STATE.AMBIENT) this._setState(STATE.GLANCE); else if (this.state === STATE.GLANCE) this._setState(STATE.AMBIENT); else if (this.state === STATE.COMMAND) this._setState(STATE.GLANCE); } @@ -653,6 +718,11 @@ export class MobilePebble extends LitElement { this.dragging = false; this.dragOffset = 0; if (this._g.longPressTimer) clearTimeout(this._g.longPressTimer); + if (this._g.isTraying) { + // Abandoned mid-pull: settle to whichever end we were nearest. + if (this.trayDrag > T_TRAY_COMMIT) this._openTray(); + else this._closeTray(); + } this._hidePeek(); this._g = null; } @@ -660,7 +730,7 @@ export class MobilePebble extends LitElement { // ----- Document-level handlers ----- _onDocumentPointerDown(e) { - if (this.state === STATE.AMBIENT) { + if (this.state === STATE.AMBIENT && !this.trayOpen) { return; } // Use composedPath so we can detect taps that fall outside the shadow root @@ -668,7 +738,12 @@ export class MobilePebble extends LitElement { if (path.includes(this)) { return; } - this._setState(STATE.AMBIENT); + if (this.trayOpen) { + this._closeTray(); + } + if (this.state !== STATE.AMBIENT) { + this._setState(STATE.AMBIENT); + } } _onDocumentKeyDown(e) { @@ -676,6 +751,7 @@ export class MobilePebble extends LitElement { return; } if (this.radialOpen) this._closeRadial(); + else if (this.trayOpen) this._closeTray(); else if (this.state !== STATE.AMBIENT) this._setState(STATE.AMBIENT); } @@ -709,6 +785,51 @@ export class MobilePebble extends LitElement { this._dispatchAction(e.detail.action); } + // ----- Quick-settings tray (pull down from ambient) ----- + + _trayElement() { + return this.renderRoot?.querySelector("pebble-tray") ?? null; + } + + /** True when the tray has at least one control worth showing. */ + _trayHasContent() { + return !!this._trayElement()?.hasContent?.(); + } + + /** + * Natural tray height, measured once it has rendered. Measured lazily rather + * than hard-coded because the tray shrinks when hardware is missing. + */ + _measureTray() { + const tray = this._trayElement(); + if (!tray) { + return 0; + } + const height = tray.naturalHeight?.() ?? 0; + if (height) { + // The pebble has to clear the card plus the gap between them. + this.trayHeight = height + TRAY_GAP; + } + return this.trayHeight; + } + + _openTray() { + if (!this._trayHasContent()) { + return; + } + this.trayDrag = 0; + this.trayOpen = true; + // Re-read the hardware: brightness may have changed from the function keys + // or another tool since the tray was last shown. + this._trayElement()?.refresh?.(); + } + + _closeTray() { + this.trayOpen = false; + this.trayDrag = 0; + this.trayDragging = false; + } + // ----- Peek (tab-switch preview) ----- _showPeek(direction) { @@ -934,10 +1055,17 @@ export class MobilePebble extends LitElement { // ----- Render ----- render() { - const dragStyle = + // The pebble rises by the tray's live height so the tray can hang beneath + // it; --pebble-tray-lift is the vertical analogue of --pebble-drag-x. + const lift = this.trayOpen ? this.trayHeight : this.trayDrag; + const dragStyle = [ this.dragging && this.dragOffset ? `--pebble-drag-x: ${this.dragOffset}px` - : ""; + : "", + lift ? `--pebble-tray-lift: ${lift}px` : "", + ] + .filter(Boolean) + .join("; "); return html`
- ${this.radialOpen ? this._renderRadial() : ""} ${this._renderPeek()} + ${this._renderTray()} ${this.radialOpen ? this._renderRadial() : ""} + ${this._renderPeek()} `; } + // The tray is a sibling of `.pebble`, not a child: `.pebble` is + // `overflow: hidden`, which would clip it (the radial does the same). + // It stays mounted so its hardware probe runs once and `hasContent()` is + // ready before the first pull-down; CSS hides it while closed. + _renderTray() { + const lift = this.trayOpen ? this.trayHeight : this.trayDrag; + // The card's own height, i.e. how far it travels to be fully revealed. The + // pebble lifts by this plus TRAY_GAP, and that difference is the visible + // gap between the two. Clamped so the rubber-banded lift can't slide the + // card past its resting place. + const card = Math.max(0, this.trayHeight - TRAY_GAP); + const reveal = Math.min(card, Math.max(0, lift)); + const style = [ + `--pebble-tray-height: ${card}px`, + reveal ? `--pebble-tray-reveal: ${reveal}px` : "", + ] + .filter(Boolean) + .join("; "); + return html``; + } + _renderAmbient() { const active = this.state === STATE.AMBIENT; const notifCount = this.notifications?.length || 0; @@ -1181,16 +1334,6 @@ export class MobilePebble extends LitElement {
${this.blockerOrigin ? this._renderBlocker() : ""} - ${ - this.hasBrightness - ? html`
-

Display

-
- -
-
` - : "" - }

Quick actions

diff --git a/ui/system/mobile/pebble_tray.css b/ui/system/mobile/pebble_tray.css new file mode 100644 index 0000000..17e4404 --- /dev/null +++ b/ui/system/mobile/pebble_tray.css @@ -0,0 +1,96 @@ +/* SPDX-License-Identifier: AGPL-3.0-or-later */ + +/* Quick-settings tray : hangs below the pebble when pulled down from the + ambient state. Same material as the pebble itself (menu surface, warm + shadow, hand-cut radii) so the two read as one object that unfurled, not a + panel stacked on top. + + The host is positioned by pebble.css; this file owns the card and its + controls only. No `infinite` animations: Servo rebuilds the display list + every frame for those and pegs the CPU (see the note in pebble.css). */ + +:host { + display: block; + pointer-events: auto; +} + +.tray-card { + display: flex; + flex-direction: column; + /* The brightness row is the reason the tray exists, so it gets more room to + breathe than the tiles below it; the gap is deliberately not uniform. */ + gap: var(--spacing-xl); + padding: 16px 18px 18px; + /* Same material as the glance/command card: the menu surface and the pebble + shadow, no border. The tray should read as the same object unfurling. */ + background: var(--bg-menu); + box-shadow: var(--pebble-shadow); + border-radius: 28px; +} + +.tray-brightness { + /* The slider fills the row; its own glyph and readout provide the labels. */ + display: block; +} + +.tray-tiles { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 1fr; + gap: var(--spacing-md); +} + +.tile { + display: flex; + align-items: center; + gap: var(--spacing-md); + min-width: 0; + /* Comfortably above the 44px touch-target floor, one-handed. */ + min-height: 52px; + padding: 0 14px; + background: var(--bg-surface); + border: 1px solid var(--color-border); + border-radius: 16px 15px 16px 15px; + color: var(--color-text); + font-family: var(--font-family-base); + font-size: var(--font-size-base); + text-align: left; + cursor: pointer; + transition: + background var(--transition-fast), + border-color var(--transition-fast), + color var(--transition-fast); +} + +.tile:active { + transform: translateY(1px); +} + +/* Lit up: the accent should feel like a firefly, not a neon sign. */ +.tile.on { + background: color-mix(in oklch, var(--color-primary) 22%, var(--bg-surface)); + border-color: color-mix(in oklch, var(--color-primary) 45%, transparent); + color: var(--color-text); +} + +.tile lucide-icon { + flex: none; + color: var(--color-text-secondary); +} + +.tile.on lucide-icon { + color: var(--color-primary); +} + +.tile-label { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +@media (prefers-reduced-motion: reduce) { + .tile { + transition-duration: 0.01s; + } +} diff --git a/ui/system/mobile/pebble_tray.js b/ui/system/mobile/pebble_tray.js new file mode 100644 index 0000000..cf7e96e --- /dev/null +++ b/ui/system/mobile/pebble_tray.js @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * `` : the quick-settings drawer that hangs below the pebble when + * pulled down from the ambient state. Holds the handful of device controls + * worth reaching in one gesture: screen brightness, Wi-Fi, torch. + * + * Owns its own hardware state (unlike the other pebble sub-elements, which are + * pure presenters) because each control's availability decides whether the tray + * has anything to show at all. `hasContent()` lets the pebble keep the gesture + * inert on hardware with none of them. + * + * Reads are deliberately cheap: the Wi-Fi tile takes the radio state and the + * active AP only, and never starts NetworkManager's AP monitoring (a GetAll per + * visible network) - this surface has to open instantly. + */ + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { screenBrightness, torch } from "beaver://shared/power/brightness.js"; +import { networkManager } from "beaver://shared/dbus/network_manager.js"; +import "beaver://shared/power/brightness_slider.js"; + +export class PebbleTray extends LitElement { + static properties = { + hasBrightness: { type: Boolean, state: true }, + hasWifi: { type: Boolean, state: true }, + hasTorch: { type: Boolean, state: true }, + wifiOn: { type: Boolean, state: true }, + wifiSsid: { type: String, state: true }, + torchOn: { type: Boolean, state: true }, + }; + + static styles = css` + @import url("beaver://system/mobile/pebble_tray.css"); + `; + + constructor() { + super(); + this.hasBrightness = false; + this.hasWifi = false; + this.hasTorch = false; + this.wifiOn = false; + this.wifiSsid = ""; + this.torchOn = false; + } + + connectedCallback() { + super.connectedCallback(); + this.#probe(); + } + + /** True once any control is available, i.e. the tray is worth opening. */ + hasContent() { + return this.hasBrightness || this.hasWifi || this.hasTorch; + } + + /** + * The tray's natural height. Measured from the inner card, because the host + * is clipped to the pulled distance (`max-height`) and would report 0 while + * closed. + */ + naturalHeight() { + const card = this.renderRoot?.querySelector(".tray-card"); + return card ? card.getBoundingClientRect().height : 0; + } + + /** Re-read every control from the hardware. Called when the tray opens. */ + async refresh() { + this.renderRoot?.querySelector("brightness-slider")?.refresh(); + await Promise.all([this.#refreshWifi(), this.#refreshTorch()]); + } + + // One-time availability probe, so absent hardware never renders a tile. + async #probe() { + const [backlight, led] = await Promise.all([ + screenBrightness.device(), + torch.device(), + ]); + this.hasBrightness = !!backlight; + this.hasTorch = !!led; + this.hasWifi = networkManager.available; + await Promise.all([this.#refreshWifi(), this.#refreshTorch()]); + this.dispatchEvent( + new CustomEvent("tray-ready", { bubbles: true, composed: true }), + ); + } + + async #refreshTorch() { + if (!this.hasTorch) { + return; + } + this.torchOn = await torch.isOn(); + } + + async #refreshWifi() { + if (!this.hasWifi) { + return; + } + try { + this.wifiOn = await networkManager.wirelessEnabled(); + this.wifiSsid = this.wifiOn ? await this.#activeSsid() : ""; + } catch { + // NetworkManager absent or refusing: leave the tile in its last state. + } + } + + // The SSID we're associated with, without starting AP monitoring. + async #activeSsid() { + const device = await networkManager.primaryWifiDevice(); + if (!device) { + return ""; + } + const ap = await device.currentAccessPoint(); + return ap?.ssid ?? ""; + } + + #onWifiToggle = async () => { + const next = !this.wifiOn; + // Optimistic: the radio takes a moment, and the tile should feel instant. + this.wifiOn = next; + this.wifiSsid = ""; + try { + await networkManager.setWirelessEnabled(next); + } catch (error) { + console.warn("[tray] Failed to toggle Wi-Fi:", error); + this.wifiOn = !next; + return; + } + if (next) { + this.wifiSsid = await this.#activeSsid(); + } + }; + + #onTorchToggle = async () => { + this.torchOn = !this.torchOn; + await torch.set(this.torchOn); + }; + + #renderWifi() { + const label = this.wifiOn ? this.wifiSsid || "Not connected" : "Off"; + return html` + + `; + } + + #renderTorch() { + return html` + + `; + } + + render() { + const tiles = []; + if (this.hasWifi) { + tiles.push(this.#renderWifi()); + } + if (this.hasTorch) { + tiles.push(this.#renderTorch()); + } + return html` +
+ ${ + this.hasBrightness + ? html`
+ +
` + : "" + } + ${tiles.length ? html`
${tiles}
` : ""} +
+ `; + } +} + +customElements.define("pebble-tray", PebbleTray); diff --git a/ui/system/services/index.js b/ui/system/services/index.js index b45b9bf..9877ed5 100644 --- a/ui/system/services/index.js +++ b/ui/system/services/index.js @@ -48,7 +48,7 @@ export function initAll( // Media session control const media = mediaSession.init(layoutManager, { - getRemoteControlTarget: () => mobileComponents().notificationSheet, + getRemoteControlTarget: () => mobileComponents().pebble, }); // P2P, task providers, search channels