feat: configure and inspect DRM outputs

This commit is contained in:
Samuel Onoja committed 2026-09-25 04:32:19 +01:00
1 parent d46becfd60
commit 07de17a399
7 files changed
+723 -48

No files matched your search

+38
View File
@@ -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 nested backend, the host compositor remains responsible for physical touchpad
configuration. 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 = "[email protected]"
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 Commands are argv arrays and are started directly, without a shell. The
default `Super+Enter` binding runs the built-in `terminal = ["foot"]` command: 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 -- focus left
cargo run -p feresectl -- workspace 2 cargo run -p feresectl -- workspace 2
cargo run -p feresectl -- get-workspaces cargo run -p feresectl -- get-workspaces
cargo run -p feresectl -- get-outputs
``` ```
The v0 IPC slice also supports `move`, `resize`, `move-to-workspace`, 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 `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 runtime directory and socket are restricted to modes `0700` and `0600`, and the
server rejects peers whose effective UID differs from the compositor's. 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 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 hidden unless the session explicitly opts in, because an input method can
+271 -45
View File
@@ -41,6 +41,7 @@ use smithay::{
use crate::{ use crate::{
Ferese, Ferese,
config::{OutputModeRequest, OutputProfile, OutputSettings, OutputTransform},
metrics::RenderMetrics, metrics::RenderMetrics,
winit::{animated_window_elements, cursorless_window_elements, redraw_output}, winit::{animated_window_elements, cursorless_window_elements, redraw_output},
}; };
@@ -50,6 +51,29 @@ pub struct DirectBackendState {
pub active: bool, pub active: bool,
devices: HashMap<DrmNode, DirectDevice>, devices: HashMap<DrmNode, DirectDevice>,
presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>, presentation: HashMap<(DrmNode, crtc::Handle), PresentationClock>,
pub(crate) connected_outputs: Vec<ConnectedOutputInfo>,
}
#[derive(Clone, Debug)]
pub(crate) struct ConnectedOutputInfo {
pub connector: String,
pub identity: String,
pub enabled: bool,
pub profile: Option<String>,
pub physical_size: Option<(u32, u32)>,
pub current_mode: Option<ConnectedModeInfo>,
pub available_modes: Vec<ConnectedModeInfo>,
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)] #[derive(Default)]
@@ -70,6 +94,7 @@ struct DirectDevice {
struct DirectOutput { struct DirectOutput {
connector: connector::Handle, connector: connector::Handle,
mode: DrmMode, mode: DrmMode,
settings: OutputSettings,
output: Output, output: Output,
global: GlobalId, global: GlobalId,
surface: GbmBufferedSurface<GbmAllocator<DrmDeviceFd>, OutputPresentationFeedback>, surface: GbmBufferedSurface<GbmAllocator<DrmDeviceFd>, OutputPresentationFeedback>,
@@ -78,6 +103,18 @@ struct DirectOutput {
frame_pending: bool, frame_pending: bool,
} }
struct OutputSelection {
connector: connector::Info,
crtc: crtc::Handle,
mode: DrmMode,
settings: OutputSettings,
}
struct OutputScan {
selections: Vec<OutputSelection>,
connected_outputs: Vec<ConnectedOutputInfo>,
}
pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> { pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> {
let (session, notifier) = LibSeatSession::new()?; let (session, notifier) = LibSeatSession::new()?;
let seat_name = session.seat(); let seat_name = session.seat();
@@ -106,6 +143,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<()
active: session_active, active: session_active,
devices: HashMap::new(), devices: HashMap::new(),
presentation: HashMap::new(), presentation: HashMap::new(),
connected_outputs: Vec::new(),
}); });
open_primary_device(event_loop, state, &primary_path)?; open_primary_device(event_loop, state, &primary_path)?;
@@ -242,10 +280,30 @@ fn open_primary_device(
state state
.dmabuf_state .dmabuf_state
.create_global::<Ferese>(&display_handle, dmabuf_formats); .create_global::<Ferese>(&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(); let mut outputs = HashMap::new();
for (connector, crtc, mode) in selections { for selection in scan.selections {
let output = create_direct_output(state, &mut drm, &gbm, &renderer, connector, crtc, mode)?; let OutputSelection {
connector,
crtc,
mode,
settings,
} = selection;
let output = create_direct_output(
state, &mut drm, &gbm, &renderer, connector, crtc, mode, &settings,
)?;
state state
.direct_backend .direct_backend
.as_mut() .as_mut()
@@ -505,25 +563,30 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
return; return;
}; };
let selections = match select_outputs(&device.drm) { let scan = match select_outputs(&device.drm, &state.output_profiles) {
Ok(selections) => selections, Ok(scan) => scan,
Err(error) if error.kind() == io::ErrorKind::NotFound => Vec::new(),
Err(error) => { Err(error) => {
tracing::error!(?node, %error, "failed to scan DRM connectors"); tracing::error!(?node, %error, "failed to scan DRM connectors");
restore_device(state, node, device); restore_device(state, node, device);
return; 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() .into_iter()
.map(|selection @ (_, crtc, _)| (crtc, selection)) .map(|selection| (selection.crtc, selection))
.collect::<HashMap<_, _>>(); .collect::<HashMap<_, _>>();
let existing = device.outputs.keys().copied().collect::<Vec<_>>(); let existing = device.outputs.keys().copied().collect::<Vec<_>>();
for crtc in existing { for crtc in existing {
let unchanged = device.outputs.get(&crtc).is_some_and(|output| { let unchanged = device.outputs.get(&crtc).is_some_and(|output| {
selections.get(&crtc).is_some_and(|(connector, _, mode)| { selections.get(&crtc).is_some_and(|selection| {
connector.handle() == output.connector && *mode == output.mode selection.connector.handle() == output.connector
&& selection.mode == output.mode
&& selection.settings == output.settings
}) })
}); });
if unchanged { 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( match create_direct_output(
state, state,
&mut device.drm, &mut device.drm,
@@ -550,6 +619,7 @@ fn rescan_device(state: &mut Ferese, node: DrmNode) {
connector, connector,
crtc, crtc,
mode, mode,
&settings,
) { ) {
Ok(output) => { Ok(output) => {
state state
@@ -587,6 +657,9 @@ fn remove_device(state: &mut Ferese, node: DrmNode) {
backend.presentation.remove(&(node, crtc)); backend.presentation.remove(&(node, crtc));
} }
} }
if let Some(backend) = state.direct_backend.as_mut() {
backend.connected_outputs.clear();
}
tracing::info!(?node, "removed DRM device"); 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<Vec<(connector::Info, crtc::Handle, DrmMode)>> { fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<OutputScan> {
let resources = drm.resource_handles()?; let resources = drm.resource_handles()?;
let connected = resources
.connectors()
.iter()
.map(|handle| drm.get_connector(*handle, true))
.collect::<io::Result<Vec<_>>>()?
.into_iter()
.filter(|connector| connector.state() == connector::State::Connected)
.collect::<Vec<_>>();
let identities = connected
.iter()
.map(|connector| connector_identity(drm, connector))
.collect::<Vec<_>>();
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 selections = Vec::new();
let mut connected_outputs = Vec::new();
let mut used_crtcs = HashSet::new(); let mut used_crtcs = HashSet::new();
for handle in resources.connectors() { for (connector, identity) in connected.into_iter().zip(identities) {
let connector = drm.get_connector(*handle, true)?; let settings = active_profile
if connector.state() != connector::State::Connected { .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; continue;
} }
let Some(mode) = connector let Some(mode) = selected_mode else {
.modes()
.iter()
.find(|mode| mode.mode_type().contains(ModeTypeFlags::PREFERRED))
.or_else(|| connector.modes().first())
.copied()
else {
continue; continue;
}; };
@@ -666,20 +788,101 @@ fn select_outputs(drm: &DrmDevice) -> io::Result<Vec<(connector::Info, crtc::Han
}); });
if let Some(crtc) = current_crtc.or(compatible_crtc) { if let Some(crtc) = current_crtc.or(compatible_crtc) {
used_crtcs.insert(crtc); used_crtcs.insert(crtc);
selections.push((connector, crtc, mode)); selections.push(OutputSelection {
connector,
crtc,
mode,
settings,
});
} }
} }
if selections.is_empty() { selections.sort_by_key(|selection| selection.settings.position.is_none());
Err(io::Error::new( Ok(OutputScan {
io::ErrorKind::NotFound, selections,
"no connected desktop DRM connector with a usable CRTC", connected_outputs,
)) })
} else { }
Ok(selections)
fn connected_mode_info(mode: DrmMode) -> 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<OutputModeRequest>,
) -> Option<DrmMode> {
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( fn create_direct_output(
state: &mut Ferese, state: &mut Ferese,
drm: &mut DrmDevice, drm: &mut DrmDevice,
@@ -688,9 +891,10 @@ fn create_direct_output(
connector: connector::Info, connector: connector::Info,
crtc: crtc::Handle, crtc: crtc::Handle,
mode: DrmMode, mode: DrmMode,
settings: &OutputSettings,
) -> Result<DirectOutput, Box<dyn Error>> { ) -> Result<DirectOutput, Box<dyn Error>> {
let identity = connector_identity(drm, &connector); 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 drm_surface = drm.create_surface(crtc, mode, &[connector.handle()])?;
let allocator = GbmAllocator::new( let allocator = GbmAllocator::new(
gbm.clone(), gbm.clone(),
@@ -709,6 +913,7 @@ fn create_direct_output(
Ok(DirectOutput { Ok(DirectOutput {
connector: connector.handle(), connector: connector.handle(),
mode, mode,
settings: settings.clone(),
output, output,
global, global,
surface, surface,
@@ -723,6 +928,7 @@ fn create_output(
connector: &connector::Info, connector: &connector::Info,
mode: DrmMode, mode: DrmMode,
identity: String, identity: String,
settings: &OutputSettings,
) -> (Output, GlobalId) { ) -> (Output, GlobalId) {
let name = connector.to_string(); let name = connector.to_string();
let physical_size = connector.size().unwrap_or((0, 0)); let physical_size = connector.size().unwrap_or((0, 0));
@@ -738,25 +944,47 @@ fn create_output(
let output_mode = OutputMode::from(mode); let output_mode = OutputMode::from(mode);
let global = output.create_global::<Ferese>(&state.display_handle); let global = output.create_global::<Ferese>(&state.display_handle);
for mode in connector.modes().iter().copied().map(OutputMode::from) {
output.add_mode(mode);
}
output.set_preferred(output_mode); output.set_preferred(output_mode);
output.change_current_state( output.change_current_state(
Some(output_mode), Some(output_mode),
Some(Transform::Normal), Some(output_transform(settings.transform)),
Some(Scale::Integer(1)), Some(Scale::Fractional(settings.scale)),
Some((0, 0).into()), Some({
let [x, y] = settings.position.unwrap_or([0, 0]);
(x, y).into()
}),
); );
let x = state let position = settings.position.unwrap_or_else(|| {
.space let x = state
.outputs() .space
.filter_map(|output| state.space.output_geometry(output)) .outputs()
.map(|geometry| geometry.loc.x + geometry.size.w) .filter_map(|output| state.space.output_geometry(output))
.max() .map(|geometry| geometry.loc.x + geometry.size.w)
.unwrap_or(0); .max()
state.space.map_output(&output, (x, 0)); .unwrap_or(0);
[x, 0]
});
state.space.map_output(&output, (position[0], position[1]));
state.register_output(&output, identity); state.register_output(&output, identity);
(output, global) (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 { fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String {
let edid = drm let edid = drm
.get_properties(connector.handle()) .get_properties(connector.handle())
@@ -775,9 +1003,7 @@ fn connector_identity(drm: &DrmDevice, connector: &connector::Info) -> String {
}); });
match edid { match edid {
Some(edid) if !edid.is_empty() => { Some(edid) if !edid.is_empty() => format!("drm-edid:{:016x}", stable_hash(&edid)),
format!("drm:{}:{:016x}", connector, stable_hash(&edid))
}
_ => format!("drm:{connector}"), _ => format!("drm:{connector}"),
} }
} }
+256 -1
View File
@@ -32,6 +32,72 @@ pub struct Config {
window_rules: Vec<WindowRuleConfig>, window_rules: Vec<WindowRuleConfig>,
#[serde(default)] #[serde(default)]
scrolling: ScrollingConfig, scrolling: ScrollingConfig,
#[serde(default)]
output_profiles: Vec<OutputProfileConfig>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputProfile {
pub name: String,
pub outputs: Vec<OutputSettings>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct OutputSettings {
pub matcher: String,
pub enabled: bool,
pub mode: Option<OutputModeRequest>,
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<u32>,
}
#[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<OutputConfig>,
}
#[derive(Clone, Debug, Deserialize)]
struct OutputConfig {
#[serde(rename = "match")]
matcher: String,
#[serde(default = "default_true")]
enabled: bool,
mode: Option<String>,
#[serde(default = "default_output_scale")]
scale: f64,
#[serde(default)]
transform: OutputTransform,
position: Option<[i32; 2]>,
} }
#[derive(Clone, Copy, Debug, PartialEq)] #[derive(Clone, Copy, Debug, PartialEq)]
@@ -427,6 +493,7 @@ pub enum ConfigError {
field: &'static str, field: &'static str,
value: String, value: String,
}, },
InvalidOutputProfile(String),
} }
impl fmt::Display for ConfigError { impl fmt::Display for ConfigError {
@@ -456,6 +523,9 @@ impl fmt::Display for ConfigError {
Self::InvalidThemeValue { field, value } => { Self::InvalidThemeValue { field, value } => {
write!(formatter, "invalid theme.{field} value {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::InvalidInputValue { .. }
| Self::InvalidBinding(_) | Self::InvalidBinding(_)
| Self::InvalidWindowRule(_) | 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) window_rules::validate(&self.window_rules).map_err(ConfigError::InvalidWindowRule)
} }
pub fn output_profiles(&self) -> Result<Vec<OutputProfile>, 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::<Result<Vec<_>, ConfigError>>()?;
Ok(OutputProfile {
name: name.to_owned(),
outputs,
})
})
.collect()
}
pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> { pub fn theme_settings(&self) -> Result<ThemeSettings, ConfigError> {
let border_width = let border_width =
nonnegative_theme_value(self.theme.geometry.border_width, "geometry.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 1.0
} }
const fn default_true() -> bool {
true
}
const fn default_output_scale() -> f64 {
1.0
}
fn parse_output_mode(value: &str) -> Result<OutputModeRequest, String> {
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::<u16>()
.map_err(|_| format!("mode {value:?} has an invalid width"))?;
let height = height
.parse::<u16>()
.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::<f64>()
.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( fn parse_column_width(
value: &ColumnWidthValue, value: &ColumnWidthValue,
field: &'static str, field: &'static str,
@@ -1519,4 +1707,71 @@ mod tests {
assert_eq!(spring.stiffness, 320.0); assert_eq!(spring.stiffness, 320.0);
assert!((spring.damping - 35.777_087_64).abs() < 0.000_001); 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 = "[email protected]"
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());
}
} }
+124 -1
View File
@@ -13,8 +13,9 @@ use ferese_ipc::{Request, Response, VERSION, read_frame, write_frame};
use ferese_layout::Direction; use ferese_layout::Direction;
use serde_json::{Value, json}; use serde_json::{Value, json};
use smithay::reexports::calloop::{EventLoop, channel}; use smithay::reexports::calloop::{EventLoop, channel};
use smithay::utils::Transform;
use crate::Ferese; use crate::{Ferese, config::OutputTransform};
const REQUEST_QUEUE_CAPACITY: usize = 128; const REQUEST_QUEUE_CAPACITY: usize = 128;
@@ -223,6 +224,79 @@ impl Ferese {
} }
fn outputs_json(&self) -> Value { 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::<Vec<_>>();
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::<Vec<_>>();
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 focused = self.output_workspaces.focused_output();
let mut outputs = self let mut outputs = self
.space .space
@@ -231,15 +305,38 @@ impl Ferese {
let id = self.output_id(output)?; let id = self.output_id(output)?;
let geometry = self.space.output_geometry(output)?; let geometry = self.space.output_geometry(output)?;
let workspace = self.output_workspaces.active_workspace(id)?; let workspace = self.output_workspaces.active_workspace(id)?;
let mode = output.current_mode();
let physical = output.physical_properties().size;
Some(json!({ Some(json!({
"id": id.0, "id": id.0,
"name": output.name(), "name": output.name(),
"connector": output.name(),
"identity": Value::Null,
"connected": true,
"enabled": true,
"focused": Some(id) == focused, "focused": Some(id) == focused,
"workspace": workspace.0, "workspace": workspace.0,
"x": geometry.loc.x, "x": geometry.loc.x,
"y": geometry.loc.y, "y": geometry.loc.y,
"width": geometry.size.w, "width": geometry.size.w,
"height": geometry.size.h, "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::<Vec<_>>(),
})) }))
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -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)] #[derive(Debug)]
struct CommandError { struct CommandError {
code: &'static str, code: &'static str,
+2
View File
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
let input_settings = config.input_settings()?; let input_settings = config.input_settings()?;
let bindings = config.bindings(&input_settings)?; let bindings = config.bindings(&input_settings)?;
let window_rules = config.window_rules()?; let window_rules = config.window_rules()?;
let output_profiles = config.output_profiles()?;
let mut event_loop = EventLoop::try_new()?; let mut event_loop = EventLoop::try_new()?;
let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?; let signals = Signals::new(&[Signal::SIGINT, Signal::SIGTERM])?;
event_loop event_loop
@@ -54,6 +55,7 @@ fn main() -> Result<(), Box<dyn Error>> {
animation_speed: config.animation_speed()?, animation_speed: config.animation_speed()?,
spring_config: config.spring_config()?, spring_config: config.spring_config()?,
viewport_spring_config: config.viewport_spring_config()?, viewport_spring_config: config.viewport_spring_config()?,
output_profiles,
}; };
let mut state = Ferese::new(&mut event_loop, display, runtime)?; let mut state = Ferese::new(&mut event_loop, display, runtime)?;
backends::init(launch.backend, &mut event_loop, &mut state)?; backends::init(launch.backend, &mut event_loop, &mut state)?;
+4 -1
View File
@@ -77,7 +77,7 @@ use smithay::{
}; };
use crate::{ use crate::{
config::{Binding, InputSettings, ThemeSettings}, config::{Binding, InputSettings, OutputProfile, ThemeSettings},
window_rules::{WindowRule, resolve as resolve_window_rules}, window_rules::{WindowRule, resolve as resolve_window_rules},
}; };
@@ -138,6 +138,7 @@ pub struct Ferese {
animation_speed: f64, animation_speed: f64,
spring_config: SpringConfig, spring_config: SpringConfig,
viewport_spring_config: SpringConfig, viewport_spring_config: SpringConfig,
pub(crate) output_profiles: Vec<OutputProfile>,
pub cursor_status: CursorImageStatus, pub cursor_status: CursorImageStatus,
pub(crate) cursor_theme: xcursor::CursorTheme, pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>, pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
@@ -195,6 +196,7 @@ pub struct RuntimeConfig {
pub animation_speed: f64, pub animation_speed: f64,
pub spring_config: SpringConfig, pub spring_config: SpringConfig,
pub viewport_spring_config: SpringConfig, pub viewport_spring_config: SpringConfig,
pub output_profiles: Vec<OutputProfile>,
} }
impl Ferese { impl Ferese {
@@ -446,6 +448,7 @@ impl Ferese {
animation_speed: config.animation_speed, animation_speed: config.animation_speed,
spring_config: config.spring_config, spring_config: config.spring_config,
viewport_spring_config: config.viewport_spring_config, viewport_spring_config: config.viewport_spring_config,
output_profiles: config.output_profiles,
cursor_status: CursorImageStatus::default_named(), cursor_status: CursorImageStatus::default_named(),
cursor_theme, cursor_theme,
named_cursors, named_cursors,
+28
View File
@@ -1099,6 +1099,7 @@ Configuration has one authoritative representation. Namespace ownership is:
[commands] launch command vectors this document [commands] launch command vectors this document
[[bindings]] compositor bindings this document [[bindings]] compositor bindings this document
[[window_rules]] window placement rules 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, There is no separate legacy visual namespace. Blur, opacity, radii, shadows,
@@ -1187,6 +1188,19 @@ disabled = true
[[window_rules]] [[window_rules]]
app_id = "org.example.DialogApp" app_id = "org.example.DialogApp"
floating = true 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 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 region, and one active workspace. v0 MAY choose preferred modes automatically
but MUST handle hotplug without losing windows. 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 On startup, the first output creates and activates workspace `1`. Selecting an
uncreated numeric workspace from `1` through `9` creates it on the focused uncreated numeric workspace from `1` through `9` creates it on the focused
output. A newly connected output receives the lowest unused numeric workspace output. A newly connected output receives the lowest unused numeric workspace