diff --git a/README.md b/README.md index 11a33e7..c1e2f0b 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,40 @@ Touchpad settings apply to libinput devices in the direct DRM session. In the nested backend, the host compositor remains responsible for physical touchpad configuration. +The direct DRM backend selects the first output profile whose listed monitors +are all connected. Match either the connector name reported by `get-outputs` +or its persistent EDID-derived identity: + +```toml +[[output_profiles]] +name = "docked" + +[[output_profiles.outputs]] +match = "HDMI-A-1" +mode = "3840x2160@119.998" +scale = 1.6 +position = [0, 0] + +[[output_profiles.outputs]] +match = "eDP-1" +enabled = false + +[[output_profiles]] +name = "laptop" + +[[output_profiles.outputs]] +match = "eDP-1" +mode = "2880x1800@120" +scale = 1.6 +position = [0, 0] +``` + +Unspecified connected outputs remain enabled with their preferred mode, scale +`1.0`, normal transform, and automatic horizontal placement. Supported +transforms are `normal`, `rotate_90`, `rotate_180`, `rotate_270`, `flipped`, +and their rotated flipped variants. Output configuration is applied at startup +and on hotplug; live configuration reload remains deferred. + Commands are argv arrays and are started directly, without a shell. The default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: @@ -159,6 +193,7 @@ presented, and are not re-applied when an application later changes its title. cargo run -p feresectl -- focus left cargo run -p feresectl -- workspace 2 cargo run -p feresectl -- get-workspaces +cargo run -p feresectl -- get-outputs ``` The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, @@ -167,6 +202,9 @@ The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, `get-outputs`. Requests use versioned, 1 MiB-limited length-prefixed JSON. The runtime directory and socket are restricted to modes `0700` and `0600`, and the server rejects peers whose effective UID differs from the compositor's. +`get-outputs` includes disabled-but-connected monitors, connector and persistent +identity matchers, current and available modes with refresh rates, fractional +scale, transform, logical position and size, and physical dimensions. Text-input-v3 is available to applications by default. Input-method-v2 is hidden unless the session explicitly opts in, because an input method can diff --git a/compositor/src/backends/direct.rs b/compositor/src/backends/direct.rs index 7b0c42c..fb68190 100644 --- a/compositor/src/backends/direct.rs +++ b/compositor/src/backends/direct.rs @@ -41,6 +41,7 @@ use smithay::{ use crate::{ Ferese, + config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform}, metrics::RenderMetrics, winit::{animated_window_elements, cursorless_window_elements, redraw_output}, }; @@ -50,6 +51,29 @@ pub struct DirectBackendState { pub active: bool, devices: HashMap, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, + pub(crate) connected_outputs: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct ConnectedOutputInfo { + pub connector: String, + pub identity: String, + pub enabled: bool, + pub profile: Option, + pub physical_size: Option<(u32, u32)>, + pub current_mode: Option, + pub available_modes: Vec, + pub scale: f64, + pub transform: OutputTransform, + pub configured_position: Option<[i32; 2]>, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ConnectedModeInfo { + pub width: u16, + pub height: u16, + pub refresh_millihertz: i32, + pub preferred: bool, } #[derive(Default)] @@ -70,6 +94,7 @@ struct DirectDevice { struct DirectOutput { connector: connector::Handle, mode: DrmMode, + settings: OutputSettings, output: Output, global: GlobalId, surface: GbmBufferedSurface, OutputPresentationFeedback>, @@ -78,6 +103,18 @@ struct DirectOutput { frame_pending: bool, } +struct OutputSelection { + connector: connector::Info, + crtc: crtc::Handle, + mode: DrmMode, + settings: OutputSettings, +} + +struct OutputScan { + selections: Vec, + connected_outputs: Vec, +} + pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<(), Box> { let (session, notifier) = LibSeatSession::new()?; let seat_name = session.seat(); @@ -106,6 +143,7 @@ pub fn init(event_loop: &mut EventLoop, state: &mut Ferese) -> Result<() active: session_active, devices: HashMap::new(), presentation: HashMap::new(), + connected_outputs: Vec::new(), }); open_primary_device(event_loop, state, &primary_path)?; @@ -242,10 +280,30 @@ fn open_primary_device( state .dmabuf_state .create_global::(&display_handle, dmabuf_formats); - let selections = select_outputs(&drm)?; + let scan = select_outputs(&drm, &state.output_profiles)?; + if scan.selections.is_empty() { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "no connected enabled DRM output with a usable CRTC", + ) + .into()); + } + state + .direct_backend + .as_mut() + .expect("direct backend state remains initialized") + .connected_outputs = scan.connected_outputs; let mut outputs = HashMap::new(); - for (connector, crtc, mode) in selections { - let output = create_direct_output(state, &mut drm, &gbm, &renderer, connector, crtc, mode)?; + for selection in scan.selections { + let OutputSelection { + connector, + crtc, + mode, + settings, + } = selection; + let output = create_direct_output( + state, &mut drm, &gbm, &renderer, connector, crtc, mode, &settings, + )?; state .direct_backend .as_mut() @@ -505,25 +563,30 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { return; }; - let selections = match select_outputs(&device.drm) { - Ok(selections) => selections, - Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(), + let scan = match select_outputs(&device.drm, &state.output_profiles) { + Ok(scan) => scan, Err(error) => { tracing::error!(?node, %error, "failed to scan DRM connectors"); restore_device(state, node, device); return; } }; - let mut selections = selections + if let Some(backend) = state.direct_backend.as_mut() { + backend.connected_outputs = scan.connected_outputs; + } + let mut selections = scan + .selections .into_iter() - .map(|selection @ (_, crtc, _)| (crtc, selection)) + .map(|selection| (selection.crtc, selection)) .collect::>(); let existing = device.outputs.keys().copied().collect::>(); for crtc in existing { let unchanged = device.outputs.get(&crtc).is_some_and(|output| { - selections.get(&crtc).is_some_and(|(connector, _, mode)| { - connector.handle() == output.connector && *mode == output.mode + selections.get(&crtc).is_some_and(|selection| { + selection.connector.handle() == output.connector + && selection.mode == output.mode + && selection.settings == output.settings }) }); if unchanged { @@ -541,7 +604,13 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { } } - for (_, (connector, crtc, mode)) in selections { + for (_, selection) in selections { + let OutputSelection { + connector, + crtc, + mode, + settings, + } = selection; match create_direct_output( state, &mut device.drm, @@ -550,6 +619,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) { connector, crtc, mode, + &settings, ) { Ok(output) => { state @@ -587,6 +657,9 @@ fn remove_device(state: &mut Ferese, node: DrmNode) { backend.presentation.remove(&(node, crtc)); } } + if let Some(backend) = state.direct_backend.as_mut() { + backend.connected_outputs.clear(); + } tracing::info!(?node, "removed DRM device"); } @@ -632,23 +705,72 @@ fn send_frame_callbacks(state: &mut Ferese, output: &Output) { } } -fn select_outputs(drm: &DrmDevice) -> io::Result> { +fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result { let resources = drm.resource_handles()?; + let connected = resources + .connectors() + .iter() + .map(|handle| drm.get_connector(*handle, true)) + .collect::>>()? + .into_iter() + .filter(|connector| connector.state() == connector::State::Connected) + .collect::>(); + let identities = connected + .iter() + .map(|connector| connector_identity(drm, connector)) + .collect::>(); + let active_profile = profiles.iter().find(|profile| { + profile.outputs.iter().all(|settings| { + connected + .iter() + .zip(&identities) + .any(|(connector, identity)| output_matches(settings, connector, identity)) + }) + }); + if let Some(profile) = active_profile { + tracing::info!(profile = %profile.name, "selected output profile"); + } + let mut selections = Vec::new(); + let mut connected_outputs = Vec::new(); let mut used_crtcs = HashSet::new(); - for handle in resources.connectors() { - let connector = drm.get_connector(*handle, true)?; - if connector.state() != connector::State::Connected { + for (connector, identity) in connected.into_iter().zip(identities) { + let settings = active_profile + .and_then(|profile| { + profile + .outputs + .iter() + .find(|settings| output_matches(settings, &connector, &identity)) + }) + .cloned() + .unwrap_or_else(|| default_output_settings(connector.to_string())); + let selected_mode = settings + .enabled + .then(|| select_mode(&connector, settings.mode)) + .flatten(); + connected_outputs.push(ConnectedOutputInfo { + connector: connector.to_string(), + identity: identity.clone(), + enabled: settings.enabled, + profile: active_profile.map(|profile| profile.name.clone()), + physical_size: connector.size(), + current_mode: selected_mode.map(|mode| connected_mode_info(mode)), + available_modes: connector + .modes() + .iter() + .copied() + .map(connected_mode_info) + .collect(), + scale: settings.scale, + transform: settings.transform, + configured_position: settings.position, + }); + if !settings.enabled { + tracing::info!(output = %connector, "output disabled by active profile"); continue; } - let Some(mode) = connector - .modes() - .iter() - .find(|mode| mode.mode_type().contains(ModeTypeFlags::PREFERRED)) - .or_else(|| connector.modes().first()) - .copied() - else { + let Some(mode) = selected_mode else { continue; }; @@ -666,20 +788,101 @@ fn select_outputs(drm: &DrmDevice) -> io::Result ConnectedModeInfo { + let (width, height) = mode.size(); + ConnectedModeInfo { + width, + height, + refresh_millihertz: OutputMode::from(mode).refresh, + preferred: mode.mode_type().contains(ModeTypeFlags::PREFERRED), } } +fn output_matches(settings: &OutputSettings, connector: &connector::Info, identity: &str) -> bool { + settings.matcher == connector.to_string() || settings.matcher == identity +} + +fn default_output_settings(matcher: String) -> OutputSettings { + OutputSettings { + matcher, + enabled: true, + mode: None, + scale: 1.0, + transform: OutputTransform::Normal, + position: None, + } +} + +fn select_mode( + connector: &connector::Info, + requested: Option, +) -> Option { + let fallback = || { + connector + .modes() + .iter() + .find(|mode| mode.mode_type().contains(ModeTypeFlags::PREFERRED)) + .or_else(|| connector.modes().first()) + .copied() + }; + let Some(requested) = requested else { + return fallback(); + }; + + let matching = connector + .modes() + .iter() + .filter(|mode| mode.size() == (requested.width, requested.height)); + let selected = if let Some(refresh) = requested.refresh_millihertz { + matching + .min_by_key(|mode| { + OutputMode::from(**mode) + .refresh + .unsigned_abs() + .abs_diff(refresh) + }) + .filter(|mode| { + OutputMode::from(**mode) + .refresh + .unsigned_abs() + .abs_diff(refresh) + <= 1_000 + }) + .copied() + } else { + matching + .max_by_key(|mode| OutputMode::from(**mode).refresh) + .copied() + }; + if selected.is_none() { + tracing::warn!( + output = %connector, + width = requested.width, + height = requested.height, + refresh_millihertz = ?requested.refresh_millihertz, + "requested output mode is unavailable; using preferred mode" + ); + } + + selected.or_else(fallback) +} + fn create_direct_output( state: &mut Ferese, drm: &mut DrmDevice, @@ -688,9 +891,10 @@ fn create_direct_output( connector: connector::Info, crtc: crtc::Handle, mode: DrmMode, + settings: &OutputSettings, ) -> Result> { let identity = connector_identity(drm, &connector); - let (output, global) = create_output(state, &connector, mode, identity); + let (output, global) = create_output(state, &connector, mode, identity, settings); let drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?; let allocator = GbmAllocator::new( gbm.clone(), @@ -709,6 +913,7 @@ fn create_direct_output( Ok(DirectOutput { connector: connector.handle(), mode, + settings: settings.clone(), output, global, surface, @@ -723,6 +928,7 @@ fn create_output( connector: &connector::Info, mode: DrmMode, identity: String, + settings: &OutputSettings, ) -> (Output, GlobalId) { let name = connector.to_string(); let physical_size = connector.size().unwrap_or((0, 0)); @@ -738,25 +944,47 @@ fn create_output( let output_mode = OutputMode::from(mode); let global = output.create_global::(&state.display_handle); + for mode in connector.modes().iter().copied().map(OutputMode::from) { + output.add_mode(mode); + } output.set_preferred(output_mode); output.change_current_state( Some(output_mode), - Some(Transform::Normal), - Some(Scale::Integer(1)), - Some((0, 0).into()), + Some(output_transform(settings.transform)), + Some(Scale::Fractional(settings.scale)), + Some({ + let [x, y] = settings.position.unwrap_or([0, 0]); + (x, y).into() + }), ); - let x = state - .space - .outputs() - .filter_map(|output| state.space.output_geometry(output)) - .map(|geometry| geometry.loc.x + geometry.size.w) - .max() - .unwrap_or(0); - state.space.map_output(&output, (x, 0)); + let position = settings.position.unwrap_or_else(|| { + let x = state + .space + .outputs() + .filter_map(|output| state.space.output_geometry(output)) + .map(|geometry| geometry.loc.x + geometry.size.w) + .max() + .unwrap_or(0); + [x, 0] + }); + state.space.map_output(&output, (position[0], position[1])); state.register_output(&output, identity); (output, global) } +fn output_transform(transform: OutputTransform) -> Transform { + match transform { + OutputTransform::Normal => Transform::Normal, + OutputTransform::Rotate90 => Transform::_90, + OutputTransform::Rotate180 => Transform::_180, + OutputTransform::Rotate270 => Transform::_270, + OutputTransform::Flipped => Transform::Flipped, + OutputTransform::Flipped90 => Transform::Flipped90, + OutputTransform::Flipped180 => Transform::Flipped180, + OutputTransform::Flipped270 => Transform::Flipped270, + } +} + fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String { let edid = drm .get_properties(connector.handle()) @@ -775,9 +1003,7 @@ fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String { }); match edid { - Some(edid) if !edid.is_empty() => { - format!("drm:{}:{:016x}", connector, stable_hash(&edid)) - } + Some(edid) if !edid.is_empty() => format!("drm-edid:{:016x}", stable_hash(&edid)), _ => format!("drm:{connector}"), } } diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 6d3f798..2cc4458 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -32,6 +32,72 @@ pub struct Config { window_rules: Vec, #[serde(default)] scrolling: ScrollingConfig, + #[serde(default)] + output_profiles: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutputProfile { + pub name: String, + pub outputs: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct OutputSettings { + pub matcher: String, + pub enabled: bool, + pub mode: Option, + pub scale: f64, + pub transform: OutputTransform, + pub position: Option<[i32; 2]>, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct OutputModeRequest { + pub width: u16, + pub height: u16, + pub refresh_millihertz: Option, +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum OutputTransform { + #[default] + Normal, + #[serde(rename = "rotate_90")] + Rotate90, + #[serde(rename = "rotate_180")] + Rotate180, + #[serde(rename = "rotate_270")] + Rotate270, + Flipped, + #[serde(rename = "flipped_90")] + Flipped90, + #[serde(rename = "flipped_180")] + Flipped180, + #[serde(rename = "flipped_270")] + Flipped270, +} + +#[derive(Clone, Debug, Deserialize)] +struct OutputProfileConfig { + name: String, + #[serde(default)] + outputs: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +struct OutputConfig { + #[serde(rename = "match")] + matcher: String, + #[serde(default = "default_true")] + enabled: bool, + mode: Option, + #[serde(default = "default_output_scale")] + scale: f64, + #[serde(default)] + transform: OutputTransform, + position: Option<[i32; 2]>, } #[derive(Clone, Copy, Debug, PartialEq)] @@ -427,6 +493,7 @@ pub enum ConfigError { field: &'static str, value: String, }, + InvalidOutputProfile(String), } impl fmt::Display for ConfigError { @@ -456,6 +523,9 @@ impl fmt::Display for ConfigError { Self::InvalidThemeValue { field, value } => { write!(formatter, "invalid theme.{field} value {value}") } + Self::InvalidOutputProfile(message) => { + write!(formatter, "invalid output profile: {message}") + } } } } @@ -471,7 +541,8 @@ impl Error for ConfigError { | Self::InvalidInputValue { .. } | Self::InvalidBinding(_) | Self::InvalidWindowRule(_) - | Self::InvalidThemeValue { .. } => None, + | Self::InvalidThemeValue { .. } + | Self::InvalidOutputProfile(_) => None, } } } @@ -622,6 +693,76 @@ impl Config { window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule) } + pub fn output_profiles(&self) -> Result, ConfigError> { + let mut names = HashSet::new(); + + self.output_profiles + .iter() + .map(|profile| { + let name = profile.name.trim(); + if name.is_empty() { + return Err(ConfigError::InvalidOutputProfile( + "profile names cannot be empty".to_owned(), + )); + } + if !names.insert(name.to_owned()) { + return Err(ConfigError::InvalidOutputProfile(format!( + "duplicate profile name {name:?}" + ))); + } + if profile.outputs.is_empty() { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} has no outputs" + ))); + } + + let mut matchers = HashSet::new(); + let outputs = profile + .outputs + .iter() + .map(|output| { + let matcher = output.matcher.trim(); + if matcher.is_empty() { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} contains an empty output matcher" + ))); + } + if !matchers.insert(matcher.to_owned()) { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} repeats output matcher {matcher:?}" + ))); + } + if !output.scale.is_finite() || output.scale <= 0.0 { + return Err(ConfigError::InvalidOutputProfile(format!( + "profile {name:?} output {matcher:?} has invalid scale {}", + output.scale + ))); + } + + Ok(OutputSettings { + matcher: matcher.to_owned(), + enabled: output.enabled, + mode: output + .mode + .as_deref() + .map(parse_output_mode) + .transpose() + .map_err(ConfigError::InvalidOutputProfile)?, + scale: output.scale, + transform: output.transform, + position: output.position, + }) + }) + .collect::, ConfigError>>()?; + + Ok(OutputProfile { + name: name.to_owned(), + outputs, + }) + }) + .collect() + } + pub fn theme_settings(&self) -> Result { let border_width = nonnegative_theme_value(self.theme.geometry.border_width, "geometry.border_width")?; @@ -1196,6 +1337,53 @@ const fn default_viewport_damping_ratio() -> f64 { 1.0 } +const fn default_true() -> bool { + true +} + +const fn default_output_scale() -> f64 { + 1.0 +} + +fn parse_output_mode(value: &str) -> Result { + let (size, refresh) = value + .trim() + .split_once('@') + .map_or((value.trim(), None), |(size, refresh)| { + (size, Some(refresh)) + }); + let (width, height) = size + .split_once('x') + .ok_or_else(|| format!("mode {value:?} must use WIDTHxHEIGHT or WIDTHxHEIGHT@REFRESH"))?; + let width = width + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid width"))?; + let height = height + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid height"))?; + if width == 0 || height == 0 { + return Err(format!("mode {value:?} dimensions must be positive")); + } + let refresh_millihertz = refresh + .map(|refresh| { + let hertz = refresh + .parse::() + .map_err(|_| format!("mode {value:?} has an invalid refresh rate"))?; + if !hertz.is_finite() || hertz <= 0.0 || hertz > u32::MAX as f64 / 1_000.0 { + return Err(format!("mode {value:?} has an invalid refresh rate")); + } + + Ok((hertz * 1_000.0).round() as u32) + }) + .transpose()?; + + Ok(OutputModeRequest { + width, + height, + refresh_millihertz, + }) +} + fn parse_column_width( value: &ColumnWidthValue, field: &'static str, @@ -1519,4 +1707,71 @@ mod tests { assert_eq!(spring.stiffness, 320.0); assert!((spring.damping - 35.777_087_64).abs() < 0.000_001); } + + #[test] + fn parses_output_profiles() { + let config = parse( + r#" +[[output_profiles]] +name = "docked" + +[[output_profiles.outputs]] +match = "HDMI-A-1" +mode = "3840x2160@119.998" +scale = 1.6 +position = [0, 0] + +[[output_profiles.outputs]] +match = "eDP-1" +enabled = false +transform = "rotate_90" +"#, + ); + + assert_eq!( + config.output_profiles().unwrap(), + vec![OutputProfile { + name: "docked".to_owned(), + outputs: vec![ + OutputSettings { + matcher: "HDMI-A-1".to_owned(), + enabled: true, + mode: Some(OutputModeRequest { + width: 3840, + height: 2160, + refresh_millihertz: Some(119_998), + }), + scale: 1.6, + transform: OutputTransform::Normal, + position: Some([0, 0]), + }, + OutputSettings { + matcher: "eDP-1".to_owned(), + enabled: false, + mode: None, + scale: 1.0, + transform: OutputTransform::Rotate90, + position: None, + }, + ], + }] + ); + } + + #[test] + fn rejects_invalid_output_profiles() { + let invalid_scale = parse( + "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nscale = 0.0", + ); + let invalid_mode = parse( + "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\nmode = \"native\"", + ); + let duplicate = parse( + "[[output_profiles]]\nname = \"bad\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"\n[[output_profiles.outputs]]\nmatch = \"eDP-1\"", + ); + + assert!(invalid_scale.output_profiles().is_err()); + assert!(invalid_mode.output_profiles().is_err()); + assert!(duplicate.output_profiles().is_err()); + } } diff --git a/compositor/src/ipc.rs b/compositor/src/ipc.rs index a436233..30d38ea 100644 --- a/compositor/src/ipc.rs +++ b/compositor/src/ipc.rs @@ -13,8 +13,9 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame}; use ferese_layout::Direction; use serde_json::{Value, json}; use smithay::reexports::calloop::{EventLoop, channel}; +use smithay::utils::Transform; -use crate::Ferese; +use crate::{Ferese, config::OutputTransform}; const REQUEST_QUEUE_CAPACITY: usize = 128; @@ -223,6 +224,79 @@ impl Ferese { } fn outputs_json(&self) -> Value { + if let Some(backend) = self.direct_backend.as_ref() { + let focused = self.output_workspaces.focused_output(); + let mut outputs = backend + .connected_outputs + .iter() + .map(|info| { + let mapped = self + .space + .outputs() + .find(|output| output.name() == info.connector); + let id = mapped.and_then(|output| self.output_id(output)); + let geometry = mapped.and_then(|output| self.space.output_geometry(output)); + let workspace = id.and_then(|id| self.output_workspaces.active_workspace(id)); + let position = geometry + .map(|geometry| [geometry.loc.x, geometry.loc.y]) + .or(info.configured_position); + let scale = mapped + .map(|output| output.current_scale().fractional_scale()) + .unwrap_or(info.scale); + let transform = mapped + .map(|output| transform_name(output.current_transform())) + .unwrap_or_else(|| configured_transform_name(info.transform)); + let modes = info + .available_modes + .iter() + .map(|mode| { + json!({ + "width": mode.width, + "height": mode.height, + "refresh_millihertz": mode.refresh_millihertz, + "refresh_hz": f64::from(mode.refresh_millihertz) / 1_000.0, + "preferred": mode.preferred, + }) + }) + .collect::>(); + let current_mode = info.current_mode.map(|mode| { + json!({ + "width": mode.width, + "height": mode.height, + "refresh_millihertz": mode.refresh_millihertz, + "refresh_hz": f64::from(mode.refresh_millihertz) / 1_000.0, + }) + }); + + json!({ + "id": id.map(|id| id.0), + "name": info.connector, + "connector": info.connector, + "identity": info.identity, + "connected": true, + "enabled": info.enabled && mapped.is_some(), + "profile": info.profile, + "focused": id.is_some() && id == focused, + "workspace": workspace.map(|workspace| workspace.0), + "x": position.map(|position| position[0]), + "y": position.map(|position| position[1]), + "width": geometry.map(|geometry| geometry.size.w), + "height": geometry.map(|geometry| geometry.size.h), + "physical_width_mm": info.physical_size.map(|size| size.0), + "physical_height_mm": info.physical_size.map(|size| size.1), + "scale": scale, + "transform": transform, + "current_mode": current_mode, + "available_modes": modes, + }) + }) + .collect::>(); + outputs.sort_by(|left, right| { + left["connector"].as_str().cmp(&right["connector"].as_str()) + }); + return Value::Array(outputs); + } + let focused = self.output_workspaces.focused_output(); let mut outputs = self .space @@ -231,15 +305,38 @@ impl Ferese { let id = self.output_id(output)?; let geometry = self.space.output_geometry(output)?; let workspace = self.output_workspaces.active_workspace(id)?; + let mode = output.current_mode(); + let physical = output.physical_properties().size; Some(json!({ "id": id.0, "name": output.name(), + "connector": output.name(), + "identity": Value::Null, + "connected": true, + "enabled": true, "focused": Some(id) == focused, "workspace": workspace.0, "x": geometry.loc.x, "y": geometry.loc.y, "width": geometry.size.w, "height": geometry.size.h, + "physical_width_mm": physical.w, + "physical_height_mm": physical.h, + "scale": output.current_scale().fractional_scale(), + "transform": transform_name(output.current_transform()), + "current_mode": mode.map(|mode| json!({ + "width": mode.size.w, + "height": mode.size.h, + "refresh_millihertz": mode.refresh, + "refresh_hz": f64::from(mode.refresh) / 1_000.0, + })), + "available_modes": output.modes().into_iter().map(|mode| json!({ + "width": mode.size.w, + "height": mode.size.h, + "refresh_millihertz": mode.refresh, + "refresh_hz": f64::from(mode.refresh) / 1_000.0, + "preferred": Some(mode) == output.preferred_mode(), + })).collect::>(), })) }) .collect::>(); @@ -248,6 +345,32 @@ impl Ferese { } } +fn transform_name(transform: Transform) -> &'static str { + match transform { + Transform::Normal => "normal", + Transform::_90 => "rotate_90", + Transform::_180 => "rotate_180", + Transform::_270 => "rotate_270", + Transform::Flipped => "flipped", + Transform::Flipped90 => "flipped_90", + Transform::Flipped180 => "flipped_180", + Transform::Flipped270 => "flipped_270", + } +} + +fn configured_transform_name(transform: OutputTransform) -> &'static str { + match transform { + OutputTransform::Normal => "normal", + OutputTransform::Rotate90 => "rotate_90", + OutputTransform::Rotate180 => "rotate_180", + OutputTransform::Rotate270 => "rotate_270", + OutputTransform::Flipped => "flipped", + OutputTransform::Flipped90 => "flipped_90", + OutputTransform::Flipped180 => "flipped_180", + OutputTransform::Flipped270 => "flipped_270", + } +} + #[derive(Debug)] struct CommandError { code: &'static str, diff --git a/compositor/src/main.rs b/compositor/src/main.rs index 950ec64..ccd3588 100644 --- a/compositor/src/main.rs +++ b/compositor/src/main.rs @@ -31,6 +31,7 @@ fn main() -> Result<(), Box> { let input_settings = config.input_settings()?; let bindings = config.bindings(&input_settings)?; let window_rules = config.window_rules()?; + let output_profiles = config.output_profiles()?; let mut event_loop = EventLoop::try_new()?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; event_loop @@ -54,6 +55,7 @@ fn main() -> Result<(), Box> { animation_speed: config.animation_speed()?, spring_config: config.spring_config()?, viewport_spring_config: config.viewport_spring_config()?, + output_profiles, }; let mut state = Ferese::new(&mut event_loop, display, runtime)?; backends::init(launch.backend, &mut event_loop, &mut state)?; diff --git a/compositor/src/state.rs b/compositor/src/state.rs index 0bad508..a6858de 100644 --- a/compositor/src/state.rs +++ b/compositor/src/state.rs @@ -77,7 +77,7 @@ use smithay::{ }; use crate::{ - config::{Binding, InputSettings, ThemeSettings}, + config::{Binding, InputSettings, OutputProfile, ThemeSettings}, window_rules::{WindowRule, resolve as resolve_window_rules}, }; @@ -138,6 +138,7 @@ pub struct Ferese { animation_speed: f64, spring_config: SpringConfig, viewport_spring_config: SpringConfig, + pub(crate) output_profiles: Vec, pub cursor_status: CursorImageStatus, pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) named_cursors: HashMap, @@ -195,6 +196,7 @@ pub struct RuntimeConfig { pub animation_speed: f64, pub spring_config: SpringConfig, pub viewport_spring_config: SpringConfig, + pub output_profiles: Vec, } impl Ferese { @@ -446,6 +448,7 @@ impl Ferese { animation_speed: config.animation_speed, spring_config: config.spring_config, viewport_spring_config: config.viewport_spring_config, + output_profiles: config.output_profiles, cursor_status: CursorImageStatus::default_named(), cursor_theme, named_cursors, diff --git a/docs/ferese-window-manager-spec.md b/docs/ferese-window-manager-spec.md index f100053..a10f2a3 100644 --- a/docs/ferese-window-manager-spec.md +++ b/docs/ferese-window-manager-spec.md @@ -1099,6 +1099,7 @@ Configuration has one authoritative representation. Namespace ownership is: [commands] launch command vectors this document [[bindings]] compositor bindings this document [[window_rules]] window placement rules this document +[[output_profiles]] direct-session output policy this document ``` There is no separate legacy visual namespace. Blur, opacity, radii, shadows, @@ -1187,6 +1188,19 @@ disabled = true [[window_rules]] app_id = "org.example.DialogApp" floating = true + +[[output_profiles]] +name = "docked" + +[[output_profiles.outputs]] +match = "HDMI-A-1" +mode = "3840x2160@120" +scale = 1.6 +position = [0, 0] + +[[output_profiles.outputs]] +match = "eDP-1" +enabled = false ``` Command values are argument arrays, never shell command strings. Binding records @@ -1381,6 +1395,20 @@ Each connected output has a mode, scale, transform, logical position, usable region, and one active workspace. v0 MAY choose preferred modes automatically but MUST handle hotplug without losing windows. +The direct backend selects the first configured output profile for which every +listed output matcher is connected. A matcher MAY be a DRM connector name or +the persistent identity reported by `get-outputs`. Each entry MAY set enabled +state, `WIDTHxHEIGHT[@REFRESH]` mode, positive fractional scale, transform, and +logical position. Unspecified connected outputs remain enabled using preferred +mode, scale `1.0`, normal transform, and automatic horizontal placement. +Invalid profiles MUST fail startup validation. An unavailable valid mode MUST +produce a warning and fall back to the preferred mode. + +`get-outputs` MUST enumerate disabled as well as enabled connected outputs and +report connector and persistent identity, active profile, current and available +modes and refresh rates, scale, transform, logical geometry when enabled, and +physical dimensions when known. + On startup, the first output creates and activates workspace `1`. Selecting an uncreated numeric workspace from `1` through `9` creates it on the focused output. A newly connected output receives the lowest unused numeric workspace