diff --git a/crates/hearthspace-compositor/src/bin/hearthspace.rs b/crates/hearthspace-compositor/src/bin/hearthspace.rs index eddcf7f..8987c44 100644 --- a/crates/hearthspace-compositor/src/bin/hearthspace.rs +++ b/crates/hearthspace-compositor/src/bin/hearthspace.rs @@ -20,7 +20,7 @@ fn main() -> Result<(), Box> { .any(|arg| arg == hearthspace_compositor::config::SHELL_FLAG) { info!("starting shell client"); - hearthspace_shell::xilem_shell::run() + hearthspace_shell::ui::run() } else { let backend = parse_backend_selection(&args)?; let headless_output_size = parse_headless_output_size(&args)?; diff --git a/crates/hearthspace-compositor/tests/waydriver_hearthspace.rs b/crates/hearthspace-compositor/tests/waydriver_hearthspace.rs index f931251..0118b6a 100644 --- a/crates/hearthspace-compositor/tests/waydriver_hearthspace.rs +++ b/crates/hearthspace-compositor/tests/waydriver_hearthspace.rs @@ -58,16 +58,15 @@ async fn waydriver_backends_drive_input_capture_and_teardown() { } #[tokio::test] -async fn waydriver_session_locates_xilem_shell_by_xpath() -> Result<(), Box> -{ +async fn waydriver_session_locates_shell_ui_by_xpath() -> Result<(), Box> { init_tracing(); let _guard = WAYDRIVER_TEST_LOCK.lock().await; let _bus = PrivateSessionBus::start()?; set_screen_reader_enabled(true).await?; - run_xilem_shell_xpath_check().await + run_shell_ui_xpath_check().await } -async fn run_xilem_shell_xpath_check() -> Result<(), Box> { +async fn run_shell_ui_xpath_check() -> Result<(), Box> { let mut compositor = HearthspaceCompositor::new(hearthspace_binary())?.with_shell(); compositor.start(Some("800x600"), Some(1.0)).await?; diff --git a/crates/hearthspace-shell/src/lib.rs b/crates/hearthspace-shell/src/lib.rs index 9053d8f..bf2e7dc 100644 --- a/crates/hearthspace-shell/src/lib.rs +++ b/crates/hearthspace-shell/src/lib.rs @@ -1,5 +1,5 @@ pub mod app_catalog; pub mod command; -pub mod xilem_shell; +pub mod ui; pub use command::{ShellCommand, SpawnTarget}; diff --git a/crates/hearthspace-shell/src/main.rs b/crates/hearthspace-shell/src/main.rs index 2a91171..c438bb6 100644 --- a/crates/hearthspace-shell/src/main.rs +++ b/crates/hearthspace-shell/src/main.rs @@ -3,7 +3,7 @@ use std::fs::OpenOptions; fn main() -> Result<(), Box> { initialize_tracing(); tracing::info!("starting Hearthspace shell"); - hearthspace_shell::xilem_shell::run() + hearthspace_shell::ui::run() } fn initialize_tracing() { diff --git a/crates/hearthspace-shell/src/ui.rs b/crates/hearthspace-shell/src/ui.rs new file mode 100644 index 0000000..38e6c97 --- /dev/null +++ b/crates/hearthspace-shell/src/ui.rs @@ -0,0 +1,43 @@ +//! Hearthspace's Xilem-rendered UI client. +//! +//! The shell runs as its own Wayland client. It creates one control-bar surface +//! per active output, launcher palette surfaces for active searches, and a tiny +//! hidden watcher surface that keeps the output list live without disturbing the +//! visible bars' accessibility trees. + +use std::{env, path::PathBuf}; + +use hearthspace_common::SHELL_COMMAND_SOCKET_ENV; +use tracing::{error, info}; +use xilem::{EventLoop, Xilem}; + +mod command_client; +mod constants; +mod output_watcher; +mod state; +mod theme; +mod views; + +use output_watcher::initial_wayland_output_names; +use state::ShellState; +use theme::{SHELL_BG, shell_properties}; +use views::app_logic; + +pub fn run() -> Result<(), Box> { + let command_socket = env::var_os(SHELL_COMMAND_SOCKET_ENV) + .map(PathBuf::from) + .ok_or_else(|| format!("{SHELL_COMMAND_SOCKET_ENV} is not set"))?; + info!(path = %command_socket.display(), "starting Xilem shell UI"); + + let output_names = initial_wayland_output_names().unwrap_or_else(|error| { + error!(%error, "failed to read initial Wayland outputs; using primary shell fallback"); + Vec::new() + }); + let state = ShellState::new(command_socket, output_names); + + let app = Xilem::new(state, app_logic) + .with_default_properties(shell_properties()) + .with_default_base_color(SHELL_BG); + app.run_in(EventLoop::with_user_event())?; + Ok(()) +} diff --git a/crates/hearthspace-shell/src/ui/command_client.rs b/crates/hearthspace-shell/src/ui/command_client.rs new file mode 100644 index 0000000..710a0e1 --- /dev/null +++ b/crates/hearthspace-shell/src/ui/command_client.rs @@ -0,0 +1,25 @@ +use std::{io::Write, os::unix::net::UnixStream, path::PathBuf}; + +use tracing::{debug, error}; + +use crate::ShellCommand; + +pub(super) fn launch_app(command_socket: &PathBuf, app_id: &str) { + send_command( + command_socket, + &ShellCommand::LaunchApp(app_id.to_string()).wire_name(), + ); +} + +pub(super) fn send_command(command_socket: &PathBuf, command: &str) { + debug!(path = %command_socket.display(), command, "sending shell command"); + match UnixStream::connect(command_socket).and_then(|mut stream| { + stream.write_all(command.as_bytes())?; + stream.write_all(b"\n") + }) { + Ok(()) => {} + Err(error) => { + error!(path = %command_socket.display(), command, %error, "failed to send shell command") + } + } +} diff --git a/crates/hearthspace-shell/src/ui/constants.rs b/crates/hearthspace-shell/src/ui/constants.rs new file mode 100644 index 0000000..228a7c0 --- /dev/null +++ b/crates/hearthspace-shell/src/ui/constants.rs @@ -0,0 +1,36 @@ +use xilem::dpi::LogicalSize; + +/// Maximum number of app search results to surface at once. +pub(super) const MAX_RESULTS: usize = 4; + +/// Width of the launcher palette dropdown, in logical pixels. +pub(super) const LAUNCHER_WIDTH: i32 = 360; +/// Approximate height of a single result row, used to size the dropdown to its +/// contents. +const LAUNCHER_ROW_HEIGHT: f64 = 40.0; +/// Vertical padding added around the launcher result list. +const LAUNCHER_PADDING: f64 = 12.0; + +/// Horizontal padding inside each launcher result button. +pub(super) const LAUNCHER_ITEM_H_PAD: f64 = 14.0; +/// Width of a launcher result label. Sized so each row spans the dropdown width +/// once the button adds [`LAUNCHER_ITEM_H_PAD`] of padding on each side. +pub(super) const LAUNCHER_ITEM_WIDTH: f64 = LAUNCHER_WIDTH as f64 - 2.0 * LAUNCHER_ITEM_H_PAD; + +/// Maximum width of the search field. +pub(super) const SEARCH_BOX_WIDTH: f64 = 420.0; +/// Fixed height of the search field. +pub(super) const SEARCH_BOX_HEIGHT: f64 = 34.0; +/// Horizontal inset between the bar's contents and the screen edges. +pub(super) const SHELL_BAR_H_PAD: f64 = 16.0; + +pub(super) const FALLBACK_OUTPUT_KEY: &str = ""; + +/// Inner size of the launcher dropdown for a given number of results. The +/// dropdown is sized to its contents so it reads as a snug menu. +pub(super) fn launcher_size(result_count: usize) -> LogicalSize { + LogicalSize::new( + f64::from(LAUNCHER_WIDTH), + result_count as f64 * LAUNCHER_ROW_HEIGHT + LAUNCHER_PADDING, + ) +} diff --git a/crates/hearthspace-shell/src/ui/output_watcher.rs b/crates/hearthspace-shell/src/ui/output_watcher.rs new file mode 100644 index 0000000..9066a33 --- /dev/null +++ b/crates/hearthspace-shell/src/ui/output_watcher.rs @@ -0,0 +1,188 @@ +use std::io; + +use tracing::error; +use wayland_client::{ + Connection, Dispatch, QueueHandle, + protocol::{wl_output, wl_registry}, +}; +use xilem::{ + core::{MessageProxy, NoElement, View}, + view::task_raw, +}; + +use super::state::{OutputNamesChanged, ShellState}; + +pub(super) fn output_watcher_task() +-> impl View + use<> { + task_raw( + move |proxy: MessageProxy, _state: &mut ShellState| async move { + let result = xilem::tokio::task::spawn_blocking(move || watch_wayland_outputs(proxy)) + .await + .unwrap_or_else(|error| Err(io::Error::other(error))); + if let Err(error) = result { + error!(%error, "Wayland output watcher stopped"); + } + }, + |state: &mut ShellState, message: OutputNamesChanged| { + state.apply_output_names(message.names); + }, + ) +} + +pub(super) fn initial_wayland_output_names() -> io::Result> { + let connection = Connection::connect_to_env().map_err(io::Error::other)?; + let mut event_queue = connection.new_event_queue(); + let queue_handle = event_queue.handle(); + let _registry = connection.display().get_registry(&queue_handle, ()); + let mut watcher = OutputWatcher::default(); + event_queue + .roundtrip(&mut watcher) + .map_err(io::Error::other)?; + Ok(watcher.output_names()) +} + +fn watch_wayland_outputs(proxy: MessageProxy) -> io::Result<()> { + let connection = Connection::connect_to_env().map_err(io::Error::other)?; + let mut event_queue = connection.new_event_queue(); + let queue_handle = event_queue.handle(); + let _registry = connection.display().get_registry(&queue_handle, ()); + let mut watcher = OutputWatcher::default(); + event_queue + .roundtrip(&mut watcher) + .map_err(io::Error::other)?; + send_output_names(&proxy, &watcher)?; + + loop { + event_queue + .blocking_dispatch(&mut watcher) + .map_err(io::Error::other)?; + if watcher.take_changed() { + send_output_names(&proxy, &watcher)?; + } + } +} + +fn send_output_names( + proxy: &MessageProxy, + watcher: &OutputWatcher, +) -> io::Result<()> { + proxy + .message(OutputNamesChanged { + names: watcher.output_names(), + }) + .map_err(|error| io::Error::other(error.to_string())) +} + +#[derive(Default)] +struct OutputWatcher { + outputs: Vec, + changed: bool, +} + +struct OutputState { + global_name: u32, + _output: wl_output::WlOutput, + name: Option, +} + +struct OutputGlobal { + global_name: u32, +} + +impl OutputWatcher { + fn bind_output( + &mut self, + registry: &wl_registry::WlRegistry, + global_name: u32, + version: u32, + queue_handle: &QueueHandle, + ) { + if self + .outputs + .iter() + .any(|output| output.global_name == global_name) + { + return; + } + + let output = registry.bind::( + global_name, + version.min(4), + queue_handle, + OutputGlobal { global_name }, + ); + self.outputs.push(OutputState { + global_name, + _output: output, + name: None, + }); + } + + fn output_mut(&mut self, global_name: u32) -> Option<&mut OutputState> { + self.outputs + .iter_mut() + .find(|output| output.global_name == global_name) + } + + fn remove_output(&mut self, global_name: u32) { + let original_len = self.outputs.len(); + self.outputs + .retain(|output| output.global_name != global_name); + self.changed |= self.outputs.len() != original_len; + } + + fn output_names(&self) -> Vec { + self.outputs + .iter() + .filter_map(|output| output.name.clone()) + .collect() + } + + fn take_changed(&mut self) -> bool { + std::mem::take(&mut self.changed) + } +} + +impl Dispatch for OutputWatcher { + fn event( + state: &mut Self, + registry: &wl_registry::WlRegistry, + event: wl_registry::Event, + _data: &(), + _connection: &Connection, + queue_handle: &QueueHandle, + ) { + match event { + wl_registry::Event::Global { + name, + interface, + version, + } if interface == "wl_output" => { + state.bind_output(registry, name, version, queue_handle); + } + wl_registry::Event::GlobalRemove { name } => state.remove_output(name), + _ => {} + } + } +} + +impl Dispatch for OutputWatcher { + fn event( + state: &mut Self, + _proxy: &wl_output::WlOutput, + event: wl_output::Event, + data: &OutputGlobal, + _connection: &Connection, + _queue_handle: &QueueHandle, + ) { + let Some(output) = state.output_mut(data.global_name) else { + return; + }; + if let wl_output::Event::Name { name } = event + && output.name.as_deref() != Some(&name) + { + output.name = Some(name); + state.changed = true; + } + } +} diff --git a/crates/hearthspace-shell/src/ui/state.rs b/crates/hearthspace-shell/src/ui/state.rs new file mode 100644 index 0000000..373bfcf --- /dev/null +++ b/crates/hearthspace-shell/src/ui/state.rs @@ -0,0 +1,166 @@ +use std::path::PathBuf; + +use hearthspace_common::{ + LAUNCHER_APP_ID, SHELL_BAR_APP_ID, launcher_app_id_for_output, shell_bar_app_id_for_output, +}; +use xilem::{AppState, WindowId}; + +use crate::app_catalog::AppCatalog; + +use super::constants::FALLBACK_OUTPUT_KEY; + +/// Reactive state backing the shell view. +pub(super) struct ShellState { + pub(super) command_socket: PathBuf, + pub(super) catalog: AppCatalog, + pub(super) outputs: Vec, + pub(super) watcher_window_id: WindowId, + running: bool, +} + +#[derive(Debug)] +pub(super) struct ShellOutputInstance { + pub(super) output_name: Option, + pub(super) query: String, + pub(super) bar_window_id: WindowId, + pub(super) launcher_window_id: WindowId, +} + +#[derive(Debug)] +pub(super) struct OutputNamesChanged { + pub(super) names: Vec, +} + +impl AppState for ShellState { + fn keep_running(&self) -> bool { + self.running + } +} + +impl ShellState { + pub(super) fn new(command_socket: PathBuf, output_names: Vec) -> Self { + let mut state = Self { + command_socket, + catalog: AppCatalog::load(), + outputs: Vec::new(), + watcher_window_id: WindowId::next(), + running: true, + }; + state.apply_output_names(output_names); + state + } + + pub(super) fn stop(&mut self) { + self.running = false; + } + + pub(super) fn apply_output_names(&mut self, output_names: Vec) { + let targets = normalized_output_targets(output_names); + let mut existing = std::mem::take(&mut self.outputs); + let mut outputs = Vec::with_capacity(targets.len()); + + for output_name in targets { + if let Some(index) = existing + .iter() + .position(|instance| instance.output_name == output_name) + { + outputs.push(existing.remove(index)); + } else { + outputs.push(ShellOutputInstance::new(output_name)); + } + } + + self.outputs = outputs; + } + + pub(super) fn output_query_mut(&mut self, output_key: &str) -> Option<&mut String> { + self.outputs + .iter_mut() + .find(|output| output.key() == output_key) + .map(|output| &mut output.query) + } +} + +impl ShellOutputInstance { + fn new(output_name: Option) -> Self { + Self { + output_name, + query: String::new(), + bar_window_id: WindowId::next(), + launcher_window_id: WindowId::next(), + } + } + + pub(super) fn key(&self) -> &str { + self.output_name.as_deref().unwrap_or(FALLBACK_OUTPUT_KEY) + } + + pub(super) fn bar_app_id(&self) -> String { + self.output_name + .as_deref() + .map(shell_bar_app_id_for_output) + .unwrap_or_else(|| SHELL_BAR_APP_ID.to_string()) + } + + pub(super) fn launcher_app_id(&self) -> String { + self.output_name + .as_deref() + .map(launcher_app_id_for_output) + .unwrap_or_else(|| LAUNCHER_APP_ID.to_string()) + } +} + +fn normalized_output_targets(output_names: Vec) -> Vec> { + let mut targets: Vec> = Vec::new(); + for name in output_names { + if name.is_empty() + || targets + .iter() + .any(|target| target.as_deref() == Some(&name)) + { + continue; + } + targets.push(Some(name)); + } + + if targets.is_empty() { + targets.push(None); + } + targets +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn output_targets_fall_back_to_primary_when_no_outputs_are_known() { + assert_eq!(normalized_output_targets(Vec::new()), vec![None]); + } + + #[test] + fn output_targets_keep_order_and_drop_duplicates() { + assert_eq!( + normalized_output_targets(vec![ + "eDP-1".to_string(), + "HDMI-A-1".to_string(), + "eDP-1".to_string(), + String::new(), + ]), + vec![Some("eDP-1".to_string()), Some("HDMI-A-1".to_string())] + ); + } + + #[test] + fn shell_state_preserves_window_ids_and_query_across_output_updates() { + let mut state = ShellState::new(PathBuf::from("/tmp/socket"), vec!["eDP-1".to_string()]); + state.outputs[0].query = "term".to_string(); + let bar_window_id = state.outputs[0].bar_window_id; + + state.apply_output_names(vec!["eDP-1".to_string(), "HDMI-A-1".to_string()]); + + assert_eq!(state.outputs[0].bar_window_id, bar_window_id); + assert_eq!(state.outputs[0].query, "term"); + assert_eq!(state.outputs[1].output_name.as_deref(), Some("HDMI-A-1")); + } +} diff --git a/crates/hearthspace-shell/src/ui/theme.rs b/crates/hearthspace-shell/src/ui/theme.rs new file mode 100644 index 0000000..130ae2b --- /dev/null +++ b/crates/hearthspace-shell/src/ui/theme.rs @@ -0,0 +1,71 @@ +use xilem::{ + Color, + masonry::{ + core::{DefaultProperties, PropertyStack, Selector}, + layout::AsUnit, + properties::{ + Background, BorderColor, BorderWidth, CaretColor, ContentColor, CornerRadius, Padding, + PlaceholderColor, + }, + theme::default_property_set, + widgets::{Button, Label, TextArea, TextInput}, + }, +}; + +use super::constants::LAUNCHER_ITEM_H_PAD; + +/// Window background for the bar and launcher palette. +pub(super) const SHELL_BG: Color = Color::from_rgb8(0xf4, 0xf4, 0xf5); +const SHELL_TEXT: Color = Color::from_rgb8(0x1f, 0x1f, 0x23); +const SHELL_PLACEHOLDER: Color = Color::from_rgb8(0x8a, 0x8a, 0x93); +const BUTTON_HOVER_BG: Color = Color::from_rgb8(0xe4, 0xe4, 0xe7); +const BUTTON_ACTIVE_BG: Color = Color::from_rgb8(0xd4, 0xd4, 0xd8); +const INPUT_BG: Color = Color::from_rgb8(0xff, 0xff, 0xff); +const INPUT_BORDER: Color = Color::from_rgb8(0xd4, 0xd4, 0xd8); +const INPUT_FOCUS_BORDER: Color = Color::from_rgb8(0x3b, 0x7e, 0xe4); + +/// Light theme overrides applied to every shell widget. +pub(super) fn shell_properties() -> DefaultProperties { + let mut properties = default_property_set(); + + properties.insert::(Background::Color(Color::TRANSPARENT)); + properties.insert::(BorderWidth { width: 0.0.px() }); + properties.insert::(CornerRadius { radius: 6.0.px() }); + properties.insert::(ContentColor::new(SHELL_TEXT)); + properties.insert::(Padding::from_vh(8.0.px(), LAUNCHER_ITEM_H_PAD.px())); + { + let mut stack = PropertyStack::new(); + stack.push( + Selector::new().with_hovered(true), + Background::Color(BUTTON_HOVER_BG), + ); + stack.push( + Selector::new().with_active(true), + Background::Color(BUTTON_ACTIVE_BG), + ); + properties.insert_stack::