feat: configure and inspect DRM outputs
This commit is contained in:
1 parent
d46becfd60
commit
07de17a399
7 files changed
+715
-40
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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| {
|
||||||
continue;
|
profile
|
||||||
}
|
.outputs
|
||||||
let Some(mode) = connector
|
.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()
|
.modes()
|
||||||
.iter()
|
.iter()
|
||||||
.find(|mode| mode.mode_type().contains(ModeTypeFlags::PREFERRED))
|
|
||||||
.or_else(|| connector.modes().first())
|
|
||||||
.copied()
|
.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;
|
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) {
|
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(
|
||||||
@@ -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,13 +944,20 @@ 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 position = settings.position.unwrap_or_else(|| {
|
||||||
let x = state
|
let x = state
|
||||||
.space
|
.space
|
||||||
.outputs()
|
.outputs()
|
||||||
@@ -752,11 +965,26 @@ fn create_output(
|
|||||||
.map(|geometry| geometry.loc.x + geometry.size.w)
|
.map(|geometry| geometry.loc.x + geometry.size.w)
|
||||||
.max()
|
.max()
|
||||||
.unwrap_or(0);
|
.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);
|
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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user