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
+715 -40

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
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
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
+263 -37
View File
@@ -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<DrmNode, DirectDevice>,
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)]
@@ -70,6 +94,7 @@ struct DirectDevice {
struct DirectOutput {
connector: connector::Handle,
mode: DrmMode,
settings: OutputSettings,
output: Output,
global: GlobalId,
surface: GbmBufferedSurface<GbmAllocator<DrmDeviceFd>, 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<OutputSelection>,
connected_outputs: Vec<ConnectedOutputInfo>,
}
pub fn init(event_loop: &mut EventLoop<Ferese>, state: &mut Ferese) -> Result<(), Box<dyn Error>> {
let (session, notifier) = LibSeatSession::new()?;
let seat_name = session.seat();
@@ -106,6 +143,7 @@ pub fn init(event_loop: &mut EventLoop<Ferese>, 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::<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();
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::<HashMap<_, _>>();
let existing = device.outputs.keys().copied().collect::<Vec<_>>();
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<Vec<(connector::Info, crtc::Handle, DrmMode)>> {
fn select_outputs(drm: &DrmDevice, profiles: &[OutputProfile]) -> io::Result<OutputScan> {
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 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 {
continue;
}
let Some(mode) = connector
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()
.find(|mode| mode.mode_type().contains(ModeTypeFlags::PREFERRED))
.or_else(|| connector.modes().first())
.copied()
else {
.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) = selected_mode else {
continue;
};
@@ -666,18 +788,99 @@ fn select_outputs(drm: &DrmDevice) -> io::Result<Vec<(connector::Info, crtc::Han
});
if let Some(crtc) = current_crtc.or(compatible_crtc) {
used_crtcs.insert(crtc);
selections.push((connector, crtc, mode));
selections.push(OutputSelection {
connector,
crtc,
mode,
settings,
});
}
}
if selections.is_empty() {
Err(io::Error::new(
io::ErrorKind::NotFound,
"no connected desktop DRM connector with a usable CRTC",
))
} else {
Ok(selections)
selections.sort_by_key(|selection| selection.settings.position.is_none());
Ok(OutputScan {
selections,
connected_outputs,
})
}
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(
@@ -688,9 +891,10 @@ fn create_direct_output(
connector: connector::Info,
crtc: crtc::Handle,
mode: DrmMode,
settings: &OutputSettings,
) -> Result<DirectOutput, Box<dyn Error>> {
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,13 +944,20 @@ fn create_output(
let output_mode = OutputMode::from(mode);
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.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 position = settings.position.unwrap_or_else(|| {
let x = state
.space
.outputs()
@@ -752,11 +965,26 @@ fn create_output(
.map(|geometry| geometry.loc.x + geometry.size.w)
.max()
.unwrap_or(0);
state.space.map_output(&output, (x, 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}"),
}
}
+256 -1
View File
@@ -32,6 +32,72 @@ pub struct Config {
window_rules: Vec<WindowRuleConfig>,
#[serde(default)]
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)]
@@ -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<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> {
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<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(
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 = "[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 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::<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 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::<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)]
struct CommandError {
code: &'static str,
+2
View File
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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)?;
+4 -1
View File
@@ -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<OutputProfile>,
pub cursor_status: CursorImageStatus,
pub(crate) cursor_theme: xcursor::CursorTheme,
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
@@ -195,6 +196,7 @@ pub struct RuntimeConfig {
pub animation_speed: f64,
pub spring_config: SpringConfig,
pub viewport_spring_config: SpringConfig,
pub output_profiles: Vec<OutputProfile>,
}
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,
+28
View File
@@ -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