diff --git a/Cargo.lock b/Cargo.lock index fa5b831..2f5aa51 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9845,6 +9845,7 @@ dependencies = [ "accesskit", "backtrace", "base64 0.22.1", + "beaver-hal", "beaver-p2p", "content-security-policy", "crossbeam-channel", diff --git a/crates/beaver_hal/src/brightness/dummy.rs b/crates/beaver_hal/src/brightness/dummy.rs new file mode 100644 index 0000000..dcb9c40 --- /dev/null +++ b/crates/beaver_hal/src/brightness/dummy.rs @@ -0,0 +1,53 @@ +// SPDX Id: AGPL-3.0-or-later + +//! Fallback brightness backend for platforms without a real one (macOS, Windows). +//! +//! Exposes a single in-memory "dummy" backlight so the brightness UI can be +//! developed and tested off-Linux: writes are kept in memory for the lifetime of +//! the process instead of touching any hardware. + +use std::sync::Mutex; + +use log::debug; + +use crate::brightness::{BrightnessDevice, DeviceKind}; + +const DEFAULT_PERCENT: f64 = 50.0; + +#[derive(Debug)] +struct DummyDevice { + name: String, + percent: Mutex, +} + +impl BrightnessDevice for DummyDevice { + fn set(&self, percent: f64) -> Result<(), Box> { + let percent = percent.clamp(0.0, 100.0); + debug!("Dummy backlight {} set to {percent:.1}%", self.name); + *self.percent.lock().expect("dummy brightness lock") = percent; + Ok(()) + } + + fn get(&self) -> f64 { + *self.percent.lock().expect("dummy brightness lock") + } + + fn is_on_off(&self) -> bool { + false + } + + fn kind(&self) -> DeviceKind { + DeviceKind::Backlight + } + + fn name(&self) -> String { + self.name.clone() + } +} + +pub(crate) fn get_all_devices() -> Vec> { + vec![Box::new(DummyDevice { + name: "dummy-backlight".to_owned(), + percent: Mutex::new(DEFAULT_PERCENT), + })] +} diff --git a/crates/beaver_hal/src/brightness/mod.rs b/crates/beaver_hal/src/brightness/mod.rs index 5f1deb7..af8156f 100644 --- a/crates/beaver_hal/src/brightness/mod.rs +++ b/crates/beaver_hal/src/brightness/mod.rs @@ -21,7 +21,10 @@ impl Display for DeviceKind { impl Copy for DeviceKind {} /// Representation of a device for which the brightness can be controlled. -pub trait BrightnessDevice: Debug { +/// +/// `Send + Sync` so embedders can share devices across threads (brightness reads +/// and writes hit the platform, so they belong on a blocking pool). +pub trait BrightnessDevice: Debug + Send + Sync { /// Set the current value, in percent. fn set(&self, percent: f64) -> Result<(), Box>; @@ -45,9 +48,9 @@ pub(crate) use linux::get_all_devices; // Fallback implementation #[cfg(not(target_os = "linux"))] -pub(crate) fn get_all_devices() -> Vec> { - vec![] -} +mod dummy; +#[cfg(not(target_os = "linux"))] +pub(crate) use dummy::get_all_devices; #[derive(Debug)] pub struct Devices { diff --git a/patches/components/constellation/Cargo.toml.patch b/patches/components/constellation/Cargo.toml.patch index 77f1696..0aeb1a3 100644 --- a/patches/components/constellation/Cargo.toml.patch +++ b/patches/components/constellation/Cargo.toml.patch @@ -1,14 +1,15 @@ --- original +++ modified -@@ -29,6 +29,7 @@ +@@ -29,6 +29,8 @@ accesskit = { workspace = true } backtrace = { workspace = true } base64 = { workspace = true } ++beaver-hal = { path = "../../../crates/beaver_hal" } +beaver-p2p = { path = "../../../crates/beaver_p2p" } content-security-policy = { workspace = true } crossbeam-channel = { workspace = true } devtools_traits = { workspace = true } -@@ -36,6 +37,9 @@ +@@ -36,6 +38,9 @@ euclid = { workspace = true } fonts = { workspace = true } ipc-channel = { workspace = true } @@ -18,7 +19,7 @@ keyboard-types = { workspace = true } layout_api = { workspace = true } log = { workspace = true } -@@ -42,13 +46,17 @@ +@@ -42,13 +47,17 @@ media = { workspace = true } net = { workspace = true } net_traits = { workspace = true } @@ -36,7 +37,7 @@ servo-background-hang-monitor = { workspace = true } servo-background-hang-monitor-api = { workspace = true } servo-base = { workspace = true } -@@ -62,10 +70,16 @@ +@@ -62,10 +71,16 @@ storage_traits = { workspace = true } stylo = { workspace = true } stylo_traits = { workspace = true } diff --git a/patches/components/constellation/constellation.rs.patch b/patches/components/constellation/constellation.rs.patch index 1f32b43..21b459c 100644 --- a/patches/components/constellation/constellation.rs.patch +++ b/patches/components/constellation/constellation.rs.patch @@ -68,7 +68,7 @@ use crate::broadcastchannel::BroadcastChannels; use crate::browsingcontext::{ AllBrowsingContextsIterator, BrowsingContext, FullyActiveBrowsingContextsIterator, -@@ -187,11 +192,17 @@ +@@ -187,11 +192,18 @@ NewBrowsingContextInfo, }; use crate::constellation_webview::ConstellationWebView; @@ -77,6 +77,7 @@ use crate::event_loop::EventLoop; +use crate::pairing::{P2pMessage, PairingService}; use crate::pipeline::Pipeline; ++use crate::power_service; use crate::process_manager::ProcessManager; use crate::serviceworker::ServiceWorkerUnprivilegedContent; use crate::session_history::{NeedsToReload, SessionHistoryChange, SessionHistoryDiff}; @@ -86,7 +87,7 @@ struct PendingApprovalNavigation { load_data: LoadData, -@@ -222,6 +233,12 @@ +@@ -222,6 +234,12 @@ /// While a completion failed, another global requested to complete the transfer. /// We are still buffering messages, and awaiting the return of the buffer from the global who failed. CompletionRequested(MessagePortRouterId, VecDeque), @@ -99,7 +100,7 @@ } #[derive(Debug)] -@@ -470,6 +487,13 @@ +@@ -470,6 +488,13 @@ /// currently being pressed. pressed_mouse_buttons: u16, @@ -113,7 +114,7 @@ /// The currently activated keyboard modifiers. active_keyboard_modifiers: Modifiers, -@@ -523,6 +547,40 @@ +@@ -523,6 +548,43 @@ /// to the `UserContents` need to be forwared to all the `ScriptThread`s that host /// the relevant `WebView`. pub(crate) user_contents_for_manager_id: FxHashMap, @@ -143,6 +144,9 @@ + #[cfg(any(target_os = "linux", target_os = "macos"))] + dbus_signal_receiver: crossbeam_channel::Receiver, + ++ /// Brightness control (screen backlight, keyboard LEDs), backed by `beaver_hal`. ++ power: power_service::PowerService, ++ + /// ATProto events emitted by `AtProtoManager`, broadcast to interested pipelines. + atproto_event_receiver: crossbeam_channel::Receiver, + @@ -154,7 +158,7 @@ } /// State needed to construct a constellation. -@@ -586,6 +644,9 @@ +@@ -586,6 +648,9 @@ /// The wake lock provider for acquiring and releasing OS-level screen wake locks. pub wake_lock_provider: Box, @@ -164,7 +168,7 @@ } /// When we are exiting a pipeline, we can either force exiting or not. A normal exit -@@ -668,6 +729,11 @@ +@@ -668,6 +733,11 @@ let broken_image_icon_data = resources::read_bytes(Resource::BrokenImageIcon); @@ -176,7 +180,7 @@ let mut constellation: Constellation = Constellation { event_loops: Default::default(), namespace_receiver, -@@ -689,7 +755,7 @@ +@@ -689,7 +759,7 @@ script_to_devtools_callback: Default::default(), #[cfg(feature = "bluetooth")] bluetooth_ipc_sender: state.bluetooth_thread, @@ -185,7 +189,7 @@ private_resource_threads: state.private_resource_threads, public_storage_threads: state.public_storage_threads, private_storage_threads: state.private_storage_threads, -@@ -726,6 +792,7 @@ +@@ -726,6 +796,7 @@ canvas: OnceCell::new(), pending_approval_navigations: Default::default(), pressed_mouse_buttons: 0, @@ -193,7 +197,7 @@ active_keyboard_modifiers: Modifiers::empty(), hard_fail, active_media_session: None, -@@ -742,6 +809,21 @@ +@@ -742,6 +813,22 @@ pending_viewport_changes: Default::default(), screenshot_readiness_requests: Vec::new(), user_contents_for_manager_id: Default::default(), @@ -205,6 +209,7 @@ + dbus: dbus_service::DbusService::new(dbus_signal_tx), + #[cfg(any(target_os = "linux", target_os = "macos"))] + dbus_signal_receiver: dbus_signal_rx, ++ power: power_service::PowerService::new(), + at_proto: AtProtoManager::new( + state.public_resource_threads.core_thread, + atproto_event_tx, @@ -215,7 +220,7 @@ }; constellation.run(); -@@ -767,6 +849,18 @@ +@@ -767,6 +854,18 @@ fn clean_up_finished_script_event_loops(&mut self) { self.event_loop_join_handles .retain(|join_handle| !join_handle.is_finished()); @@ -234,7 +239,7 @@ self.event_loops .retain(|event_loop| event_loop.upgrade().is_some()); } -@@ -1046,6 +1140,11 @@ +@@ -1046,6 +1145,11 @@ .get(&webview_id) .and_then(|webview| webview.user_content_manager_id); @@ -246,7 +251,7 @@ let new_pipeline_info = NewPipelineInfo { parent_info: parent_pipeline_id, new_pipeline_id, -@@ -1057,6 +1156,13 @@ +@@ -1057,6 +1161,13 @@ user_content_manager_id, theme, target_snapshot_params, @@ -260,7 +265,7 @@ }; let pipeline = match Pipeline::spawn(new_pipeline_info, event_loop, self, throttled) { Ok(pipeline) => pipeline, -@@ -1222,6 +1328,9 @@ +@@ -1222,6 +1333,9 @@ Script((WebViewId, PipelineId, ScriptToConstellationMessage)), BackgroundHangMonitor(HangMonitorAlert), Embedder(EmbedderToConstellationMessage), @@ -270,7 +275,7 @@ RemoveProcess(usize), } // Get one incoming request. -@@ -1241,6 +1350,28 @@ +@@ -1241,6 +1355,28 @@ sel.recv(&self.background_hang_monitor_receiver); sel.recv(&self.embedder_to_constellation_receiver); @@ -299,7 +304,7 @@ self.process_manager.register(&mut sel); let request = { -@@ -1267,9 +1398,30 @@ +@@ -1267,9 +1403,30 @@ oper.recv(&self.embedder_to_constellation_receiver) .expect("Unexpected embedder channel panic in constellation"), )), @@ -331,7 +336,7 @@ let _ = oper.recv(self.process_manager.receiver_at(process_index)); Ok(Request::RemoveProcess(process_index)) }, -@@ -1292,6 +1444,37 @@ +@@ -1292,6 +1449,37 @@ Request::BackgroundHangMonitor(message) => { self.handle_request_from_background_hang_monitor(message); }, @@ -369,7 +374,7 @@ Request::RemoveProcess(index) => self.process_manager.remove(index), } } -@@ -1443,6 +1626,15 @@ +@@ -1443,6 +1631,15 @@ EmbedderToConstellationMessage::ThemeChange(webview_id, theme) => { self.handle_theme_change(webview_id, theme); }, @@ -385,7 +390,7 @@ EmbedderToConstellationMessage::TickAnimation(webview_ids) => { self.handle_tick_animation(webview_ids) }, -@@ -1515,11 +1707,7 @@ +@@ -1515,11 +1712,7 @@ } }, EmbedderToConstellationMessage::PreferencesUpdated(updates) => { @@ -398,7 +403,7 @@ let _ = event_loop.send(ScriptThreadMessage::PreferencesUpdated( updates .iter() -@@ -1546,6 +1734,18 @@ +@@ -1546,6 +1739,18 @@ EmbedderToConstellationMessage::SetAccessibilityActive(webview_id, active) => { self.set_accessibility_active(webview_id, active); }, @@ -417,7 +422,7 @@ } } -@@ -1743,7 +1943,13 @@ +@@ -1743,7 +1948,13 @@ return warn!("Attempt to add channel name from an unexpected origin."); } self.broadcast_channels @@ -432,7 +437,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelNameInRouter( router_id, -@@ -1757,7 +1963,13 @@ +@@ -1757,7 +1968,13 @@ return warn!("Attempt to remove channel name from an unexpected origin."); } self.broadcast_channels @@ -447,7 +452,7 @@ }, ScriptToConstellationMessage::RemoveBroadcastChannelRouter(router_id, origin) => { if self -@@ -1769,6 +1981,12 @@ +@@ -1769,6 +1986,12 @@ self.broadcast_channels .remove_broadcast_channel_router(router_id); }, @@ -460,7 +465,7 @@ ScriptToConstellationMessage::ScheduleBroadcast(router_id, message) => { if self .check_origin_against_pipeline(&source_pipeline_id, &message.origin) -@@ -1778,8 +1996,15 @@ +@@ -1778,8 +2001,15 @@ "Attempt to schedule broadcast from an origin not matching the origin of the msg." ); } @@ -477,7 +482,7 @@ }, ScriptToConstellationMessage::PipelineExited => { self.handle_pipeline_exited(source_pipeline_id); -@@ -1799,6 +2024,12 @@ +@@ -1799,6 +2029,12 @@ ScriptToConstellationMessage::CreateAuxiliaryWebView(load_info) => { self.handle_script_new_auxiliary(load_info); }, @@ -490,7 +495,7 @@ ScriptToConstellationMessage::ChangeRunningAnimationsState(animation_state) => { self.handle_change_running_animations_state(source_pipeline_id, animation_state) }, -@@ -1850,7 +2081,7 @@ +@@ -1850,7 +2086,7 @@ ScriptToConstellationMessage::SetFinalUrl(final_url) => { // The script may have finished loading after we already started shutting down. if let Some(ref mut pipeline) = self.pipelines.get_mut(&source_pipeline_id) { @@ -499,7 +504,7 @@ } else { warn!("constellation got set final url message for dead pipeline"); } -@@ -2000,6 +2231,29 @@ +@@ -2000,6 +2236,29 @@ new_value, ); }, @@ -529,7 +534,7 @@ ScriptToConstellationMessage::MediaSessionEvent(pipeline_id, event) => { // Unlikely at this point, but we may receive events coming from // different media sessions, so we set the active media session based -@@ -2018,7 +2272,12 @@ +@@ -2018,7 +2277,12 @@ }; self.active_media_session = Some(pipeline_id); self.constellation_to_embedder_proxy.send( @@ -543,7 +548,7 @@ ); }, #[cfg(feature = "webgpu")] -@@ -2092,9 +2351,1062 @@ +@@ -2092,9 +2356,1071 @@ } }, }, @@ -929,6 +934,15 @@ + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + let _ = callback.send(Err("D-Bus not available on this platform".to_owned())); + }, ++ ScriptToConstellationMessage::PowerListDevices(callback) => { ++ self.power.list_devices(callback); ++ }, ++ ScriptToConstellationMessage::PowerGetBrightness(id, callback) => { ++ self.power.get_brightness(id, callback); ++ }, ++ ScriptToConstellationMessage::PowerSetBrightness(id, percent) => { ++ self.power.set_brightness(id, percent); ++ }, + ScriptToConstellationMessage::CreatePeerStream( + peer_id, + local_port_id, @@ -1606,7 +1620,7 @@ /// Check the origin of a message against that of the pipeline it came from. /// Note: this is still limited as a security check, /// see -@@ -2411,6 +3723,55 @@ +@@ -2411,6 +3737,55 @@ TransferState::TransferInProgress(queue) => queue.push_back(task), TransferState::CompletionFailed(queue) => queue.push_back(task), TransferState::CompletionRequested(_, queue) => queue.push_back(task), @@ -1662,7 +1676,7 @@ } } -@@ -3222,6 +4583,101 @@ +@@ -3222,6 +4597,101 @@ ); } @@ -1764,7 +1778,7 @@ fn forward_input_event( &mut self, webview_id: WebViewId, -@@ -3241,6 +4697,66 @@ +@@ -3241,6 +4711,66 @@ let pressed_mouse_buttons = self.pressed_mouse_buttons; let active_keyboard_modifiers = self.active_keyboard_modifiers; @@ -1831,7 +1845,7 @@ let event_id = event.id; let Some(webview) = self.webviews.get_mut(&webview_id) else { warn!("Got input event for unknown WebViewId: {webview_id:?}"); -@@ -3343,6 +4859,40 @@ +@@ -3343,6 +4873,40 @@ /// fn handle_close_top_level_browsing_context(&mut self, webview_id: WebViewId) { debug!("{webview_id}: Closing"); @@ -1872,7 +1886,7 @@ let browsing_context_id = BrowsingContextId::from(webview_id); // Step 5. Remove traversable from the user agent's top-level traversable set. let browsing_context = -@@ -3619,8 +5169,27 @@ +@@ -3619,8 +5183,27 @@ opener_webview_id, opener_pipeline_id, response_sender, @@ -1900,7 +1914,7 @@ let Some((webview_id_sender, webview_id_receiver)) = generic_channel::channel() else { warn!("Failed to create channel"); let _ = response_sender.send(None); -@@ -3719,6 +5288,402 @@ +@@ -3719,6 +5302,402 @@ }); } @@ -2303,7 +2317,7 @@ #[servo_tracing::instrument(skip_all)] fn handle_refresh_cursor(&self, pipeline_id: PipelineId) { let Some(pipeline) = self.pipelines.get(&pipeline_id) else { -@@ -4268,7 +6233,7 @@ +@@ -4268,7 +6247,7 @@ }, }; @@ -2312,7 +2326,7 @@ match self.browsing_contexts.get_mut(&browsing_context_id) { Some(browsing_context) => { let old_pipeline_id = browsing_context.pipeline_id; -@@ -4277,6 +6242,7 @@ +@@ -4277,6 +6256,7 @@ old_pipeline_id, browsing_context.parent_pipeline_id, browsing_context.webview_id, @@ -2320,7 +2334,7 @@ ) }, None => { -@@ -4286,6 +6252,15 @@ +@@ -4286,6 +6266,15 @@ self.unload_document(old_pipeline_id); @@ -2336,7 +2350,7 @@ if let Some(new_pipeline) = self.pipelines.get(&new_pipeline_id) { if let Some(ref chan) = self.devtools_sender { let state = NavigationState::Start(new_pipeline.url.clone()); -@@ -4854,7 +6829,7 @@ +@@ -4854,7 +6843,7 @@ } #[servo_tracing::instrument(skip_all)] @@ -2345,7 +2359,7 @@ // Send a flat projection of the history to embedder. // The final vector is a concatenation of the URLs of the past // entries, the current entry and the future entries. -@@ -4966,9 +6941,22 @@ +@@ -4966,9 +6955,22 @@ self.constellation_to_embedder_proxy .send(ConstellationToEmbedderMsg::HistoryChanged( webview_id, @@ -2369,7 +2383,7 @@ } #[servo_tracing::instrument(skip_all)] -@@ -4987,7 +6975,7 @@ +@@ -4987,7 +6989,7 @@ webview.focused_browsing_context_id = change.browsing_context_id; } @@ -2378,7 +2392,7 @@ match self.browsing_contexts.get_mut(&change.browsing_context_id) { Some(browsing_context) => { debug!("Adding pipeline to existing browsing context."); -@@ -4994,11 +6982,15 @@ +@@ -4994,11 +6996,15 @@ let old_pipeline_id = browsing_context.pipeline_id; browsing_context.pipelines.insert(change.new_pipeline_id); browsing_context.update_current_entry(change.new_pipeline_id); @@ -2396,7 +2410,7 @@ }, }; -@@ -5006,6 +6998,18 @@ +@@ -5006,6 +7012,18 @@ self.unload_document(old_pipeline_id); } diff --git a/patches/components/constellation/lib.rs.patch b/patches/components/constellation/lib.rs.patch index 179f82c..6ab64ab 100644 --- a/patches/components/constellation/lib.rs.patch +++ b/patches/components/constellation/lib.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -7,18 +7,23 @@ +@@ -7,18 +7,24 @@ #[macro_use] mod tracing; @@ -16,6 +16,7 @@ mod logging; +mod pairing; mod pipeline; ++mod power_service; mod process_manager; mod sandboxing; mod serviceworker; diff --git a/patches/components/constellation/power_service.rs.patch b/patches/components/constellation/power_service.rs.patch new file mode 100644 index 0000000..6b3571d --- /dev/null +++ b/patches/components/constellation/power_service.rs.patch @@ -0,0 +1,138 @@ +--- original ++++ modified +@@ -0,0 +1,135 @@ ++// SPDX-License-Identifier: AGPL-3.0-or-later ++ ++//! Screen/keyboard brightness control. ++//! ++//! The devices are enumerated once, lazily, and addressed afterwards by their ++//! index in that list (`BrightnessDeviceInfo::id`). Enumeration, reads and writes ++//! all touch the platform (sysfs, and a blocking logind D-Bus call on Linux), so ++//! they run on the blocking pool rather than the constellation thread. ++ ++use std::collections::HashMap; ++use std::sync::{Arc, Mutex, OnceLock}; ++ ++use beaver_hal::brightness::{BrightnessDevice, DeviceKind, Devices}; ++use log::{error, warn}; ++use net::async_runtime::spawn_blocking; ++use servo_base::generic_channel::GenericCallback; ++use servo_constellation_traits::BrightnessDeviceInfo; ++ ++/// The enumerated devices, shared with the blocking tasks that use them. ++type DeviceCell = Arc>>>; ++ ++/// Device id -> the newest percent not yet written. A single entry exists while ++/// a writer task is running for that device. ++type PendingWrites = Arc>>>; ++ ++pub(crate) struct PowerService { ++ devices: DeviceCell, ++ pending: PendingWrites, ++} ++ ++/// Enumerate the devices on first use and keep them for the process lifetime. ++/// Only ever called from inside a blocking task: scanning sysfs and reading each ++/// device's `max_brightness` is real I/O. ++fn devices_of(cell: &OnceLock>>) -> &[Box] { ++ cell.get_or_init(|| { ++ let Devices { leds, backlight } = Devices::default(); ++ // Backlights first so id 0 is the screen on typical hardware. ++ backlight.into_iter().chain(leds).collect() ++ }) ++} ++ ++impl PowerService { ++ pub(crate) fn new() -> Self { ++ Self { ++ devices: Arc::new(OnceLock::new()), ++ pending: Arc::new(Mutex::new(HashMap::new())), ++ } ++ } ++ ++ /// Reply with the list of devices and their current brightness. ++ pub(crate) fn list_devices( ++ &self, ++ callback: GenericCallback, String>>, ++ ) { ++ let cell = self.devices.clone(); ++ spawn_blocking(move || { ++ let infos = devices_of(&cell) ++ .iter() ++ .enumerate() ++ .map(|(index, device)| BrightnessDeviceInfo { ++ id: index as u32, ++ name: device.name(), ++ kind: format!("{}", device.kind()), ++ is_on_off: device.is_on_off(), ++ percent: device.get(), ++ }) ++ .collect(); ++ if let Err(err) = callback.send(Ok(infos)) { ++ error!("[Power] Failed to send device list: {err:?}"); ++ } ++ }); ++ } ++ ++ /// Reply with a single device's current brightness, in percent. ++ pub(crate) fn get_brightness(&self, id: u32, callback: GenericCallback>) { ++ let cell = self.devices.clone(); ++ spawn_blocking(move || { ++ let response = match devices_of(&cell).get(id as usize) { ++ Some(device) => Ok(device.get()), ++ None => Err(format!("No brightness device with id {id}")), ++ }; ++ if let Err(err) = callback.send(response) { ++ error!("[Power] Failed to send brightness: {err:?}"); ++ } ++ }); ++ } ++ ++ /// Set a device's brightness. Fire and forget: the JS side exposes this as a ++ /// plain property write, so failures are logged rather than reported back. ++ /// ++ /// Writes are coalesced per device: a slider drag asks for a new value every ++ /// frame, but the hardware write is slow (a blocking logind call), so at most ++ /// one is in flight and only the newest queued value survives. That keeps the ++ /// blocking pool from filling up with stale writes, and guarantees the last ++ /// value requested is the one the hardware ends on. ++ pub(crate) fn set_brightness(&self, id: u32, percent: f64) { ++ { ++ let mut pending = self.pending.lock().expect("power pending lock"); ++ if let Some(slot) = pending.get_mut(&id) { ++ // A writer is already running for this device: let it pick this up. ++ *slot = Some(percent); ++ return; ++ } ++ // Claim the writer slot for this device, with nothing queued behind. ++ pending.insert(id, None); ++ } ++ ++ let cell = self.devices.clone(); ++ let pending = self.pending.clone(); ++ spawn_blocking(move || { ++ let Some(device) = devices_of(&cell).get(id as usize) else { ++ warn!("[Power] No brightness device with id {id}"); ++ pending.lock().expect("power pending lock").remove(&id); ++ return; ++ }; ++ ++ let mut next = Some(percent); ++ while let Some(percent) = next { ++ if let Err(err) = device.set(percent) { ++ warn!( ++ "[Power] Failed to set {} to {percent:.1}%: {err}", ++ device.name() ++ ); ++ } ++ // Take whatever arrived while we were writing; release the writer ++ // slot under the same lock so a new request can't be dropped. ++ let mut pending = pending.lock().expect("power pending lock"); ++ next = pending.get_mut(&id).and_then(Option::take); ++ if next.is_none() { ++ pending.remove(&id); ++ } ++ } ++ }); ++ } ++} diff --git a/patches/components/constellation/tracing.rs.patch b/patches/components/constellation/tracing.rs.patch index b892742..9cedf06 100644 --- a/patches/components/constellation/tracing.rs.patch +++ b/patches/components/constellation/tracing.rs.patch @@ -53,7 +53,7 @@ Self::ActivateDocument => target!("ActivateDocument"), Self::SetDocumentState(..) => target!("SetDocumentState"), Self::SetFinalUrl(..) => target!("SetFinalUrl"), -@@ -192,6 +203,77 @@ +@@ -192,6 +203,80 @@ Self::TriggerGarbageCollection => target!("TriggerGarbageCollection"), Self::AcquireWakeLock(..) => target!("AcquireWakeLock"), Self::ReleaseWakeLock(..) => target!("ReleaseWakeLock"), @@ -128,6 +128,9 @@ + Self::DBusSetProperty(..) => target!("DBusSetProperty"), + Self::DBusSubscribe(..) => target!("DBusSubscribe"), + Self::DBusUnsubscribe(..) => target!("DBusUnsubscribe"), ++ Self::PowerListDevices(..) => target!("PowerListDevices"), ++ Self::PowerGetBrightness(..) => target!("PowerGetBrightness"), ++ Self::PowerSetBrightness(..) => target!("PowerSetBrightness"), } } } diff --git a/patches/components/script/dom/brightnessdevice.rs.patch b/patches/components/script/dom/brightnessdevice.rs.patch new file mode 100644 index 0000000..d3c6cef --- /dev/null +++ b/patches/components/script/dom/brightnessdevice.rs.patch @@ -0,0 +1,129 @@ +--- original ++++ modified +@@ -0,0 +1,126 @@ ++/* SPDX Id: AGPL-3.0-or-later */ ++ ++//! A single brightness-controllable device (screen backlight, keyboard LED), ++//! backed by the `beaver_hal` crate through the constellation. ++ ++use std::cell::Cell; ++use std::rc::Rc; ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use script_bindings::domstring::DOMString; ++use script_bindings::error::Error; ++use script_bindings::num::Finite; ++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::reflector::DomGlobal; ++use crate::dom::bindings::root::DomRoot; ++use crate::dom::globalscope::GlobalScope; ++use crate::dom::promise::Promise; ++use crate::routed_promise::{RoutedPromiseListener, callback_promise}; ++ ++#[dom_struct] ++pub(crate) struct BrightnessDevice { ++ _reflector: Reflector, ++ /// Index of this device in the constellation's device list. ++ id: u32, ++ name: String, ++ kind: String, ++ is_on_off: bool, ++ /// Last known brightness in percent: refreshed when the device is enumerated ++ /// and updated optimistically on write, so reads never block. ++ percent: Cell, ++} ++ ++impl BrightnessDevice { ++ fn new_inherited(info: BrightnessDeviceInfo) -> BrightnessDevice { ++ BrightnessDevice { ++ _reflector: Reflector::new(), ++ id: info.id, ++ name: info.name, ++ kind: info.kind, ++ is_on_off: info.is_on_off, ++ percent: Cell::new(info.percent), ++ } ++ } ++ ++ pub(crate) fn new( ++ cx: &mut JSContext, ++ global: &GlobalScope, ++ info: BrightnessDeviceInfo, ++ ) -> DomRoot { ++ reflect_dom_object_with_cx(Box::new(BrightnessDevice::new_inherited(info)), global, cx) ++ } ++} ++ ++impl BrightnessDeviceMethods for BrightnessDevice { ++ fn Brightness(&self) -> Finite { ++ Finite::wrap(self.percent.get()) ++ } ++ ++ /// Apply a new brightness. The hardware write happens in the background (it ++ /// can block on Linux), so this updates the cached value immediately and lets ++ /// the constellation log any failure. ++ fn SetBrightness(&self, percent: Finite) { ++ let percent = percent.clamp(0.0, 100.0); ++ self.percent.set(percent); ++ ++ let _ = self.global().script_to_constellation_chan().send( ++ ScriptToConstellationMessage::PowerSetBrightness(self.id, percent), ++ ); ++ } ++ ++ /// Re-read the hardware and refresh the cached value. Needed because the ++ /// cache only tracks this page's own writes: the level can also change from ++ /// outside (function keys, brightnessctl, logind). ++ /// TODO: replace by an "onchange" event. ++ fn Read(&self, cx: &mut JSContext) -> Rc { ++ let global = &self.global(); ++ let promise = Promise::new(cx, global); ++ let task_manager = global.task_manager(); ++ let task_source = task_manager.dom_manipulation_task_source(); ++ let callback = callback_promise(&promise, self, task_source); ++ ++ let chan = global.script_to_constellation_chan(); ++ if chan ++ .send(ScriptToConstellationMessage::PowerGetBrightness( ++ self.id, callback, ++ )) ++ .is_err() ++ { ++ promise.reject_error(cx, Error::Operation(None)); ++ } ++ promise ++ } ++ ++ fn Name(&self) -> DOMString { ++ DOMString::from(self.name.clone()) ++ } ++ ++ fn Kind(&self) -> DOMString { ++ DOMString::from(self.kind.clone()) ++ } ++ ++ fn IsOnOff(&self) -> bool { ++ self.is_on_off ++ } ++} ++ ++impl RoutedPromiseListener> for BrightnessDevice { ++ fn handle_response( ++ &self, ++ cx: &mut JSContext, ++ response: Result, ++ promise: &Rc, ++ ) { ++ match response { ++ Ok(percent) => { ++ self.percent.set(percent); ++ promise.resolve_native(cx, &percent); ++ }, ++ Err(msg) => promise.reject_error(cx, Error::Operation(Some(msg))), ++ } ++ } ++} diff --git a/patches/components/script/dom/embedder.rs.patch b/patches/components/script/dom/embedder.rs.patch index 3ca076e..879b00b 100644 --- a/patches/components/script/dom/embedder.rs.patch +++ b/patches/components/script/dom/embedder.rs.patch @@ -1,6 +1,6 @@ --- original +++ modified -@@ -0,0 +1,546 @@ +@@ -0,0 +1,553 @@ +/* SPDX Id: AGPL-3.0-or-later */ + +//! The `Embedder` interface provides communication between web content and the embedder. @@ -40,6 +40,7 @@ +use crate::dom::eventtarget::EventTarget; +use crate::dom::globalscope::GlobalScope; +use crate::dom::pairing::Pairing; ++use crate::dom::power::Power; + +#[dom_struct] +pub(crate) struct Embedder { @@ -47,6 +48,7 @@ + pairing: MutNullableDom, + content_blocker: MutNullableDom, + dbus: MutNullableDom, ++ power: MutNullableDom, +} + +impl Embedder { @@ -56,6 +58,7 @@ + pairing: Default::default(), + content_blocker: Default::default(), + dbus: Default::default(), ++ power: Default::default(), + } + } + @@ -527,6 +530,10 @@ + self.dbus.or_init(|| DBus::new(cx, &self.global())) + } + ++ fn Power(&self, cx: &mut JSContext) -> DomRoot { ++ self.power.or_init(|| Power::new(cx, &self.global())) ++ } ++ + // Event handler for servo error events + event_handler!(servoerror, GetOnservoerror, SetOnservoerror); + diff --git a/patches/components/script/dom/mod.rs.patch b/patches/components/script/dom/mod.rs.patch index a188e69..07adb59 100644 --- a/patches/components/script/dom/mod.rs.patch +++ b/patches/components/script/dom/mod.rs.patch @@ -8,7 +8,15 @@ pub(crate) mod audio; pub(crate) use self::audio::*; pub(crate) mod bindings; -@@ -230,8 +231,10 @@ +@@ -222,6 +223,7 @@ + pub(crate) mod bluetooth; + #[cfg(feature = "bluetooth")] + pub(crate) use self::bluetooth::*; ++pub(crate) mod brightnessdevice; + pub(crate) mod broadcastchannel; + mod canvas; + pub(crate) use self::canvas::*; +@@ -230,8 +232,10 @@ pub(crate) mod clipboard; pub(crate) use self::clipboard::*; pub(crate) mod console; @@ -19,7 +27,7 @@ pub(crate) use self::credentialmanagement::*; pub(crate) mod css; pub(crate) use self::css::*; -@@ -254,6 +257,7 @@ +@@ -254,6 +258,7 @@ pub(crate) mod elementinternals; pub(crate) mod encoding; pub(crate) use self::encoding::*; @@ -27,7 +35,7 @@ pub(crate) mod event; pub(crate) use self::event::*; pub(crate) mod eventsource; -@@ -281,6 +285,7 @@ +@@ -281,6 +286,7 @@ pub(crate) use self::indexeddb::*; pub(crate) mod intersectionobserver; pub(crate) use self::intersectionobserver::*; @@ -35,7 +43,7 @@ pub(crate) mod media; pub(crate) use self::media::*; pub(crate) mod mimetype; -@@ -292,6 +297,10 @@ +@@ -292,10 +298,15 @@ pub(crate) mod node; pub(crate) use self::node::*; pub(crate) mod notification; @@ -46,3 +54,8 @@ pub(crate) mod performance; pub(crate) use self::performance::*; pub(crate) mod permission; + pub(crate) use self::permission::*; ++pub(crate) mod power; + pub(crate) mod processingoptions; + pub(crate) mod promise; + pub(crate) use self::promise::*; diff --git a/patches/components/script/dom/power.rs.patch b/patches/components/script/dom/power.rs.patch new file mode 100644 index 0000000..fbc9804 --- /dev/null +++ b/patches/components/script/dom/power.rs.patch @@ -0,0 +1,80 @@ +--- original ++++ modified +@@ -0,0 +1,77 @@ ++/* SPDX Id: AGPL-3.0-or-later */ ++ ++//! `navigator.embedder.power`: control of the device's brightness-capable ++//! hardware. ++ ++use std::rc::Rc; ++ ++use dom_struct::dom_struct; ++use js::context::JSContext; ++use script_bindings::error::Error; ++use script_bindings::reflector::{Reflector, reflect_dom_object_with_cx}; ++use servo_constellation_traits::{BrightnessDeviceInfo, ScriptToConstellationMessage}; ++ ++use crate::dom::bindings::codegen::Bindings::PowerBinding::PowerMethods; ++use crate::dom::bindings::reflector::DomGlobal; ++use crate::dom::bindings::root::DomRoot; ++use crate::dom::brightnessdevice::BrightnessDevice; ++use crate::dom::globalscope::GlobalScope; ++use crate::dom::promise::Promise; ++use crate::routed_promise::{RoutedPromiseListener, callback_promise}; ++ ++#[dom_struct] ++pub(crate) struct Power { ++ _reflector: Reflector, ++} ++ ++impl Power { ++ fn new_inherited() -> Power { ++ Power { ++ _reflector: Reflector::new(), ++ } ++ } ++ ++ pub(crate) fn new(cx: &mut JSContext, global: &GlobalScope) -> DomRoot { ++ reflect_dom_object_with_cx(Box::new(Power::new_inherited()), global, cx) ++ } ++} ++ ++impl PowerMethods for Power { ++ fn Devices(&self, cx: &mut JSContext) -> Rc { ++ let global = &self.global(); ++ let promise = Promise::new(cx, global); ++ let task_manager = global.task_manager(); ++ let task_source = task_manager.dom_manipulation_task_source(); ++ let callback = callback_promise(&promise, self, task_source); ++ ++ let chan = global.script_to_constellation_chan(); ++ if chan ++ .send(ScriptToConstellationMessage::PowerListDevices(callback)) ++ .is_err() ++ { ++ promise.reject_error(cx, Error::Operation(None)); ++ } ++ promise ++ } ++} ++ ++impl RoutedPromiseListener, String>> for Power { ++ fn handle_response( ++ &self, ++ cx: &mut JSContext, ++ response: Result, String>, ++ promise: &Rc, ++ ) { ++ match response { ++ Ok(infos) => { ++ let global = self.global(); ++ let devices: Vec> = infos ++ .into_iter() ++ .map(|info| BrightnessDevice::new(cx, &global, info)) ++ .collect(); ++ promise.resolve_native(cx, &devices); ++ }, ++ Err(msg) => promise.reject_error(cx, Error::Operation(Some(msg))), ++ } ++ } ++} diff --git a/patches/components/script_bindings/codegen/Bindings.conf.patch b/patches/components/script_bindings/codegen/Bindings.conf.patch index ba96448..ab2f81f 100644 --- a/patches/components/script_bindings/codegen/Bindings.conf.patch +++ b/patches/components/script_bindings/codegen/Bindings.conf.patch @@ -24,7 +24,7 @@ 'Attr': { 'implicitCxSetters': True, }, -@@ -127,6 +144,14 @@ +@@ -127,6 +144,22 @@ 'cx': ['Assert', 'Count', 'CountReset', 'Debug', 'Dir', 'Error', 'Group', 'GroupCollapsed', 'Info', 'Log', 'Time', 'TimeEnd', 'TimeLog', 'Trace', 'Warn'], }, @@ -35,23 +35,31 @@ +'DBus': { + 'cx': ['Call', 'GetProperty', 'SetProperty', 'Subscribe', 'Unsubscribe'], +}, ++ ++'Power': { ++ 'cx': ['Devices'], ++}, ++ ++'BrightnessDevice': { ++ 'cx': ['Read'], ++}, + 'CookieStore': { 'cx': ['Set', 'Set_', 'Get', 'Get_', 'GetAll', 'GetAll_', 'Delete', 'Delete_'], }, -@@ -424,6 +449,11 @@ +@@ -424,6 +457,11 @@ 'cx': ['CheckValidity', 'GetValidity', 'GetLabels', 'ReportValidity', 'SetValidity', 'States'], }, +'Embedder': { + 'additionalTraits': ['crate::interfaces::EmbedderHelpers'], -+ 'cx': ['ContentBlocker', 'Dbus', 'Pairing'], ++ 'cx': ['ContentBlocker', 'Dbus', 'Pairing', 'Power'], +}, + 'Event': { 'cx': ['TimeStamp'], }, -@@ -792,6 +822,10 @@ +@@ -792,6 +830,10 @@ 'weakReferenceable': True, }, @@ -62,7 +70,7 @@ 'IDBCursor': { 'cx': ['Key', 'PrimaryKey'] }, -@@ -832,6 +866,10 @@ +@@ -832,6 +874,10 @@ 'cx': ['Thresholds'] }, @@ -73,7 +81,7 @@ 'KeyframeEffect': { 'cx': ['GetKeyframes', 'SetKeyframes'] }, -@@ -903,7 +941,7 @@ +@@ -903,7 +949,7 @@ }, 'Navigator': { @@ -82,7 +90,7 @@ 'Storage', 'Plugins', 'UserActivation', 'WakeLock', 'Xr', 'MediaDevices', 'MediaSession', 'Permissions', 'GetGamepads'], }, -@@ -957,6 +995,10 @@ +@@ -957,6 +1003,10 @@ 'cx': ['RegisterPaint'], }, @@ -93,7 +101,7 @@ 'Performance': { 'cx': ['Mark', 'Measure', 'Navigation'], }, -@@ -1338,6 +1380,7 @@ +@@ -1338,6 +1388,7 @@ 'additionalTraits': ['crate::interfaces::WindowHelpers', 'crate::interfaces::HasOrigin'], 'realm': ['CreateImageBitmap', 'CreateImageBitmap_', 'WebdriverCallback', 'GetOpener', 'Fetch'], 'cx': [ diff --git a/patches/components/script_bindings/webidls/Power.webidl.patch b/patches/components/script_bindings/webidls/Power.webidl.patch new file mode 100644 index 0000000..abe0d3b --- /dev/null +++ b/patches/components/script_bindings/webidls/Power.webidl.patch @@ -0,0 +1,41 @@ +--- original ++++ modified +@@ -0,0 +1,38 @@ ++/* SPDX Id: AGPL-3.0-or-later */ ++ ++// Control of the device's brightness-capable hardware (screen backlight, keyboard LEDs). ++ ++[Exposed=Window, ++Func="Embedder::is_allowed_to_embed"] ++interface BrightnessDevice { ++ // The current brightness, in percent (0..100). ++ attribute double brightness; ++ ++ // Re-read the hardware and refresh `brightness`, resolving with the fresh ++ // value. Use when something outside this page may have changed the level ++ // (function keys, brightnessctl, logind). ++ // TODO: replace by an "onchange" event. ++ Promise read(); ++ ++ // The platform name of the device, e.g. "intel_backlight". ++ readonly attribute DOMString name; ++ ++ // "backlight" or "led". ++ readonly attribute DOMString kind; ++ ++ // True when the device only supports 0% and 100%. ++ readonly attribute boolean isOnOff; ++}; ++ ++[Exposed=Window, ++Func="Embedder::is_allowed_to_embed"] ++interface Power { ++ // All brightness-controllable devices, backlights first. Resolves with an ++ // empty list on platforms with no such hardware. ++ Promise> devices(); ++}; ++ ++partial interface Embedder { ++ [Func="Embedder::is_allowed_to_embed"] ++ readonly attribute Power power; ++}; diff --git a/patches/components/shared/constellation/from_script_message.rs.patch b/patches/components/shared/constellation/from_script_message.rs.patch index 30dac0f..2202464 100644 --- a/patches/components/shared/constellation/from_script_message.rs.patch +++ b/patches/components/shared/constellation/from_script_message.rs.patch @@ -126,8 +126,8 @@ + SearchUrl(String, bool), + /// Run a read-only SQL query over the local store: (sql, text params). + QueryStore(String, Vec), -+} -+ + } + +/// Data returned by com.atproto.server.createSession xrpc calls. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] @@ -183,8 +183,8 @@ + pub did: String, + pub active: bool, + pub status: Option, - } - ++} ++ +#[derive(Debug, Deserialize, Serialize)] +pub enum AtProtoResult { + NewSession(AtProtoNewSession, ServoUrl), // (session, endpoint_url) @@ -261,7 +261,7 @@ /// Mark a new document as active ActivateDocument, /// Set the document state for a pipeline (used by screenshot / reftests) -@@ -803,6 +984,189 @@ +@@ -803,6 +984,198 @@ /// aggregate lock count and notify the provider only when the count transitions from N to 0. /// ReleaseWakeLock(WakeLockType), @@ -400,6 +400,15 @@ + ), + /// D-Bus: unsubscribe from a signal. + DBusUnsubscribe(u32, GenericCallback>), ++ /// Power: list the brightness-controllable devices. ++ PowerListDevices(GenericCallback, String>>), ++ /// Power: read a device's brightness, in percent. ++ /// Args: device id, callback. ++ PowerGetBrightness(u32, GenericCallback>), ++ /// Power: set a device's brightness, in percent. ++ /// Args: device id, percent. Fire and forget: the DOM exposes this as a plain ++ /// property write, so there is no reply and failures are logged. ++ PowerSetBrightness(u32, f64), + /// Create a peer stream: create a virtual remote port entangled with a local port, + /// and send the offer to a remote peer. + /// Args: peer_id, local_port_id, remote_port_id, target_url, callback. diff --git a/patches/components/shared/constellation/lib.rs.patch b/patches/components/shared/constellation/lib.rs.patch index daf936d..ce27766 100644 --- a/patches/components/shared/constellation/lib.rs.patch +++ b/patches/components/shared/constellation/lib.rs.patch @@ -19,7 +19,7 @@ }; pub use from_script_message::*; use malloc_size_of_derive::MallocSizeOf; -@@ -30,15 +32,202 @@ +@@ -30,15 +32,218 @@ use rustc_hash::FxHashMap; use serde::{Deserialize, Serialize}; use servo_base::cross_process_instant::CrossProcessInstant; @@ -128,6 +128,22 @@ + Array(Vec), +} + ++/// Static description of a brightness-controllable device (screen backlight, ++/// keyboard LED, ...) exposed by `beaver_hal`, in a form that crosses IPC. ++#[derive(Clone, Debug, Deserialize, Serialize)] ++pub struct BrightnessDeviceInfo { ++ /// Index into the constellation's device list, used to address the device ++ /// in later get/set messages. ++ pub id: u32, ++ pub name: String, ++ /// "backlight" or "led". ++ pub kind: String, ++ /// True when only 0% and 100% are meaningful values. ++ pub is_on_off: bool, ++ /// Current brightness, in percent, at enumeration time. ++ pub percent: f64, ++} ++ +/// A D-Bus signal received from a subscription. +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct DbusSignalEvent { @@ -224,7 +240,7 @@ /// Messages to the Constellation from the embedding layer, whether from `ServoRenderer` or /// from `libservo` itself. #[derive(IntoStaticStr)] -@@ -55,6 +244,15 @@ +@@ -55,6 +260,15 @@ ChangeViewportDetails(WebViewId, ViewportDetails, WindowSizeType), /// Inform the constellation of a theme change. ThemeChange(WebViewId, Theme), @@ -240,7 +256,7 @@ /// Requests that the constellation instruct script/layout to try to layout again and tick /// animations. TickAnimation(Vec), -@@ -116,6 +314,9 @@ +@@ -116,6 +330,9 @@ UpdatePinchZoomInfos(PipelineId, PinchZoomInfos), /// Activate or deactivate accessibility features for the given `WebView`. SetAccessibilityActive(WebViewId, bool), diff --git a/ui/shared/power/brightness.js b/ui/shared/power/brightness.js new file mode 100644 index 0000000..79a4aa7 --- /dev/null +++ b/ui/shared/power/brightness.js @@ -0,0 +1,71 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// 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. + +// The DOM power object, or null on a page/build that can't reach it. +function powerApi() { + return globalThis.navigator?.embedder?.power ?? null; +} + +class ScreenBrightness { + #ready = null; + + /** + * Resolve (once) the backlight this UI controls, or null when the device has + * none (a desktop without a controllable panel). + */ + 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(); + // Prefer a real backlight and ignore LEDs. + return devices.find((device) => device.kind === "backlight") ?? null; + } catch (error) { + console.warn("[brightness] Failed to list power devices:", error); + return null; + } + } + + /** + * Re-read the hardware and return the current brightness in percent, or null + * when there is no device. Picks up changes made outside this page (function + * keys, brightnessctl); `device().brightness` alone only reflects our writes. + */ + async get() { + const device = await this.device(); + if (!device) { + return null; + } + try { + return await device.read(); + } catch { + // Hardware read failed: fall back to the last known value. + return device.brightness; + } + } + + /** + * Set the brightness, in percent. Applied in the background by the embedder; + * safe to call at pointermove rate. The DOM setter clamps to 0..100. + */ + async set(percent) { + const device = await this.device(); + if (device) { + device.brightness = percent; + } + } +} + +export const screenBrightness = new ScreenBrightness(); diff --git a/ui/shared/power/brightness_slider.js b/ui/shared/power/brightness_slider.js new file mode 100644 index 0000000..c65f8e1 --- /dev/null +++ b/ui/shared/power/brightness_slider.js @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +// : a drag-to-set track for the screen backlight. +// +// Shadow DOM with inline styles so it can be dropped into either shell. +// It renders nothing at all when the device has no controllable backlight. + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import { screenBrightness } from "beaver://shared/power/brightness.js"; + +// Never let the screen go fully dark from this control: the user would have no +// way to see the UI to turn it back up. +const MIN_PERCENT = 5; + +export class BrightnessSlider extends LitElement { + // Pointer currently captured for a drag, or null. + #pointerId = null; + // Track geometry, captured at pointerdown for the duration of the drag. + #rect = null; + // rAF handle coalescing hardware writes while dragging. + #pendingWrite = null; + + static properties = { + percent: { type: Number }, + }; + + static styles = css` + :host { + display: block; + } + + /* display:block above beats the UA [hidden] rule. */ + :host([hidden]) { + display: none; + } + + .row { + display: flex; + align-items: center; + gap: var(--spacing-sm, 8px); + } + + .glyph { + flex: none; + color: var(--color-text-tertiary, #999); + font-size: var(--font-size-sm, 13px); + line-height: 1; + } + + .track { + position: relative; + flex: 1; + height: 6px; + border-radius: 3px; + background: var(--color-border, #444); + cursor: pointer; + touch-action: none; + } + + .fill { + height: 100%; + border-radius: 3px; + background: var(--color-primary, #4a8); + } + + .thumb { + position: absolute; + top: 50%; + width: 14px; + height: 14px; + border-radius: 50%; + background: var(--color-primary, #4a8); + box-shadow: 0 1px 3px rgb(0 0 0 / 0.4); + transform: translate(-50%, -50%); + pointer-events: none; + } + + .value { + flex: none; + min-width: 3.5ch; + text-align: right; + color: var(--color-text-tertiary, #999); + font-size: var(--font-size-sm, 13px); + font-variant-numeric: tabular-nums; + } + `; + + constructor() { + super(); + this.percent = MIN_PERCENT; + // Hidden until a backlight is found, so shells can mount us unconditionally. + this.hidden = true; + } + + connectedCallback() { + super.connectedCallback(); + this.#init(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.#endDrag(); + } + + async #init() { + const device = await screenBrightness.device(); + if (!device) { + this.hidden = true; + return; + } + this.hidden = false; + this.percent = Math.round(device.brightness); + } + + /** Re-read the hardware; call when the containing panel opens. */ + async refresh() { + const value = await screenBrightness.get(); + if (value !== null) { + this.percent = Math.round(value); + } + } + + /** + * Map a pointer position to a percentage and schedule the write. The track + * rect is captured once per drag (a layout read per pointermove would be + * wasteful), and writes are coalesced to one per frame because each one + * crosses IPC into a blocking hardware call. + */ + #apply(event) { + const rect = this.#rect; + if (!rect) { + return; + } + const ratio = (event.clientX - rect.left) / rect.width; + const percent = Math.round( + Math.min(100, Math.max(MIN_PERCENT, ratio * 100)), + ); + if (percent === this.percent) { + return; + } + this.percent = percent; + if (this.#pendingWrite === null) { + this.#pendingWrite = requestAnimationFrame(() => { + this.#pendingWrite = null; + screenBrightness.set(this.percent); + }); + } + } + + #onPointerDown = (event) => { + event.preventDefault(); + const track = event.currentTarget; + this.#rect = track.getBoundingClientRect(); + this.#pointerId = event.pointerId; + // Route every later move/up for this pointer to the track, so the drag + // survives the cursor leaving it. + track.setPointerCapture(event.pointerId); + // Capture alone isn't enough here: crossing into an embedded lets + // the iframe take the pointer and strand us mid-drag. This class disables + // their hit-testing for the duration (rule in desktop/working.css). + document.body.classList.add("workshop-resizing"); + this.#apply(event); + }; + + #onPointerMove = (event) => { + if (event.pointerId === this.#pointerId) { + this.#apply(event); + } + }; + + #onPointerUp = (event) => { + if (event.pointerId === this.#pointerId) { + this.#endDrag(); + } + }; + + #endDrag() { + if (this.#pointerId === null) { + return; + } + const track = this.renderRoot?.querySelector(".track"); + if (track?.hasPointerCapture(this.#pointerId)) { + track.releasePointerCapture(this.#pointerId); + } + this.#pointerId = null; + document.body.classList.remove("workshop-resizing"); + this.#rect = null; + // Flush the last position rather than dropping it with the frame. + if (this.#pendingWrite !== null) { + cancelAnimationFrame(this.#pendingWrite); + this.#pendingWrite = null; + screenBrightness.set(this.percent); + } + } + + render() { + return html` +
+ +
+
+
+
+ ${this.percent}% +
+ `; + } + + #onKeyDown = (event) => { + const step = + event.key === "ArrowLeft" || event.key === "ArrowDown" + ? -5 + : event.key === "ArrowRight" || event.key === "ArrowUp" + ? 5 + : 0; + if (!step) { + return; + } + event.preventDefault(); + this.percent = Math.min(100, Math.max(MIN_PERCENT, this.percent + step)); + screenBrightness.set(this.percent); + }; +} + +customElements.define("brightness-slider", BrightnessSlider); diff --git a/ui/system/desktop/brightness.js b/ui/system/desktop/brightness.js new file mode 100644 index 0000000..74c2142 --- /dev/null +++ b/ui/system/desktop/brightness.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * Brightness : drives the dock tray's brightness button and its popover. + * + * The tray button is hidden entirely on hardware with no controllable backlight + * so the chrome only advertises the control when it works. + */ + +import { Popover, dockAnchorPosition } from "./popover.js"; +import { screenBrightness } from "beaver://shared/power/brightness.js"; + +/** + * @param {object} deps + * @param {HTMLElement|null} deps.trayButton The `#tray-brightness` trigger. + * @param {HTMLElement|null} deps.popoverEl The `` element. + * @returns {{ open: Function, close: Function, isOpen: Function }} + */ +export function initBrightness({ trayButton, popoverEl }) { + const popover = new Popover(popoverEl, { dataAttr: "brightnessPopoverOpen" }); + + // Only show the tray glyph once we know there is something to control. + // `device()` resolves to null rather than rejecting when there's no backlight. + if (trayButton) { + trayButton.hidden = true; + } + screenBrightness.device().then((device) => { + if (device && trayButton) { + trayButton.hidden = false; + } + }); + + function open(anchor) { + if (!popoverEl) { + return; + } + // The panel may have been open while something else changed the level. + popoverEl.refresh?.(); + popover.open(anchor, dockAnchorPosition(anchor)); + } + + function close() { + popover.close(); + } + + if (trayButton) { + trayButton.addEventListener("click", () => { + if (popover.isOpen()) { + close(); + } else { + open(trayButton); + } + }); + } + + return { open, close, isOpen: () => popover.isOpen() }; +} diff --git a/ui/system/desktop/brightness_popover.js b/ui/system/desktop/brightness_popover.js new file mode 100644 index 0000000..561fc0b --- /dev/null +++ b/ui/system/desktop/brightness_popover.js @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +/** + * : the dock tray's display panel, holding the screen + * brightness slider. Mirrors the other tray popovers (repos, peers): a shadow-DOM + * dialog positioned by the shared `Popover` helper. + */ + +import { + LitElement, + html, + css, +} from "beaver://shared/third_party/lit/lit-all.min.js"; +import "beaver://shared/power/brightness_slider.js"; + +export class BrightnessPopover extends LitElement { + static styles = css` + :host { + position: fixed; + z-index: var(--z-modal); + width: 260px; + padding: var(--spacing-md, 12px) var(--spacing-lg, 16px); + background: var(--bg-menu); + border: 1px solid var(--color-border); + /* Slightly irregular radii, matching the other dock popovers. */ + border-radius: 12px 14px 12px 13px; + box-shadow: var(--workshop-shadow); + color: var(--color-text); + font-family: var(--font-family-base); + } + + :host([hidden]) { + display: none; + } + + .title { + margin: 0 0 var(--spacing-sm, 8px); + font-size: var(--font-size-sm, 13px); + font-weight: var(--font-weight-bold, 600); + color: var(--color-text-tertiary, #999); + } + `; + + // Re-read the hardware value; called by the controller when opening. + refresh() { + this.renderRoot?.querySelector("brightness-slider")?.refresh(); + } + + render() { + return html` +

Display

+ + `; + } +} + +customElements.define("brightness-popover", BrightnessPopover); diff --git a/ui/system/desktop/dock.css b/ui/system/desktop/dock.css index a1a0d0e..a0ca43a 100644 --- a/ui/system/desktop/dock.css +++ b/ui/system/desktop/dock.css @@ -182,6 +182,12 @@ body[data-dock-hidden] .dock { color: var(--color-text); } +/* `display: inline-flex` above beats the UA `[hidden]` rule, so glyphs that are + conditionally present (brightness, on hardware with no backlight) need this. */ +.tray-glyph[hidden] { + display: none; +} + /* The repos "@" glyph tints to the brand color while the background indexer is mid-sync, so activity is visible without opening the popover. */ .tray-glyph.tray-repos[data-syncing] { diff --git a/ui/system/desktop/index.html b/ui/system/desktop/index.html index e0b28db..5ee3648 100644 --- a/ui/system/desktop/index.html +++ b/ui/system/desktop/index.html @@ -27,6 +27,7 @@ + @@ -227,6 +228,13 @@ aria-label="Indexed repositories" > + +