feat: add per output max-bpc config and ipc action

List current max-bpc values in outputs.

fix: remove 16 bpc and change default behaviour

feat(ipc): add max_bpc and format to output

fix: bpc on output config change

docs: add bpc to Outputs

feat: use atomic commits for connector properties

fix: drm `value_type` breaking change

fix: minor changes based on PR review

Rename bpc to max-bpc.

Add max-bpc output action.

refactor: add set_connector_properties

Fix niri-config parse test.

fix: bail when outside valid max bpc range
This commit is contained in:
Michael Yang
2026-01-01 05:45:17 +11:00
committed by Ivan Molodetskikh
parent 9a6f31012d
commit c5253968b4
9 changed files with 274 additions and 56 deletions
+18
View File
@@ -15,6 +15,7 @@ output "eDP-1" {
variable-refresh-rate // on-demand=true
focus-at-startup
backdrop-color "#001100"
max-bpc 8
hot-corners {
// off
@@ -279,6 +280,23 @@ output "HDMI-A-1" {
}
```
### `max-bpc`
<sup>Since: next release</sup>
Set the maximum bits per channel (BPC) for this output.
You *do not* need to set this option unless you hit bandwidth issues (can't set a monitor configuration that works on other compositor) or lower-than-expected color depth.
Particularly, 10-bit color output may work even if max-bpc is 8.
Valid values are `6`, `8`, `10`, `12`, `14`, `16`.
```kdl
// Set 8 max-bpc on HDMI-A-1 to lower the bandwidth.
output "HDMI-A-1" {
max-bpc 8
}
```
### `hot-corners`
<sup>Since: 25.11</sup>
+9 -1
View File
@@ -745,6 +745,7 @@ mod tests {
transform "flipped-90"
position x=10 y=20
mode "1920x1080@144"
max-bpc 10
variable-refresh-rate on-demand=true
background-color "rgba(25, 25, 102, 1.0)"
hot-corners {
@@ -857,7 +858,7 @@ mod tests {
window-open { off; }
window-close {
curve "cubic-bezier" 0.05 0.7 0.1 1
curve "cubic-bezier" 0.05 0.7 0.1 1
}
recent-windows-close {
@@ -1160,6 +1161,11 @@ mod tests {
y: 20,
},
),
max_bpc: Some(
MaxBpc(
_10,
),
),
mode: Some(
Mode {
custom: false,
@@ -1205,6 +1211,7 @@ mod tests {
scale: None,
transform: Normal,
position: None,
max_bpc: None,
mode: Some(
Mode {
custom: true,
@@ -1231,6 +1238,7 @@ mod tests {
scale: None,
transform: Normal,
position: None,
max_bpc: None,
mode: None,
modeline: Some(
Modeline {
+42
View File
@@ -59,6 +59,8 @@ pub struct Output {
pub transform: Transform,
#[knuffel(child)]
pub position: Option<Position>,
#[knuffel(child, unwrap(argument))]
pub max_bpc: Option<MaxBpc>,
#[knuffel(child)]
pub mode: Option<Mode>,
#[knuffel(child)]
@@ -101,6 +103,7 @@ impl Default for Output {
scale: None,
transform: Transform::Normal,
position: None,
max_bpc: None,
mode: None,
modeline: None,
variable_refresh_rate: None,
@@ -128,6 +131,9 @@ pub struct Position {
pub y: i32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct MaxBpc(pub niri_ipc::MaxBpc);
#[derive(knuffel::Decode, Debug, Clone, PartialEq, Default)]
pub struct Vrr {
#[knuffel(property, default = false)]
@@ -257,6 +263,42 @@ impl OutputName {
}
}
impl<S: ErrorSpan> knuffel::DecodeScalar<S> for MaxBpc {
fn type_check(
type_name: &Option<knuffel::span::Spanned<knuffel::ast::TypeName, S>>,
ctx: &mut Context<S>,
) {
if let Some(type_name) = &type_name {
ctx.emit_error(DecodeError::unexpected(
type_name,
"type name",
"no type name expected for this node",
));
}
}
fn raw_decode(
value: &knuffel::span::Spanned<knuffel::ast::Literal, S>,
ctx: &mut Context<S>,
) -> Result<Self, DecodeError<S>> {
match &**value {
knuffel::ast::Literal::Int(ref val) => match u8::try_from(val) {
Ok(v) => niri_ipc::MaxBpc::try_from(v)
.map(MaxBpc)
.map_err(|e| DecodeError::conversion(value, e)),
Err(e) => {
ctx.emit_error(DecodeError::conversion(value, e));
Ok(Self::default())
}
},
_ => {
ctx.emit_error(DecodeError::scalar_kind(knuffel::decode::Kind::Int, value));
Ok(Self::default())
}
}
}
}
impl<S: ErrorSpan> knuffel::Decode<S> for Mode {
fn decode_node(node: &SpannedNode<S>, ctx: &mut Context<S>) -> Result<Self, DecodeError<S>> {
if let Some(type_name) = &node.type_name {
+58
View File
@@ -1097,6 +1097,12 @@ pub enum OutputAction {
#[cfg_attr(feature = "clap", command(flatten))]
vrr: VrrToSet,
},
/// Set the maximum bits per channel (bit depth).
MaxBpc {
/// Maximum bits per channel to set.
#[cfg_attr(feature = "clap", arg())]
max_bpc: MaxBpc,
},
}
/// Output mode to set.
@@ -1228,6 +1234,8 @@ pub struct Output {
///
/// `None` if the output is not mapped to any logical output (for example, if it is disabled).
pub logical: Option<LogicalOutput>,
/// Maximum bits per channel (bit depth), if known.
pub max_bpc: Option<u8>,
}
/// Output mode.
@@ -1291,6 +1299,32 @@ pub enum Transform {
Flipped270,
}
/// Output maximum bits per channel.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "clap", derive(clap::ValueEnum))]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
pub enum MaxBpc {
/// 6-bit.
#[serde(rename = "6")]
_6 = 6,
/// 8-bit.
#[default]
#[serde(rename = "8")]
_8 = 8,
/// 10-bit.
#[serde(rename = "10")]
_10 = 10,
/// 12-bit.
#[serde(rename = "12")]
_12 = 12,
/// 14-bit.
#[serde(rename = "14")]
_14 = 14,
/// 16-bit.
#[serde(rename = "16")]
_16 = 16,
}
/// Toplevel window.
#[derive(Serialize, Deserialize, Debug, Clone)]
#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))]
@@ -1868,6 +1902,30 @@ impl FromStr for Transform {
}
}
impl TryFrom<u8> for MaxBpc {
type Error = &'static str;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
6 => Ok(MaxBpc::_6),
8 => Ok(MaxBpc::_8),
10 => Ok(MaxBpc::_10),
12 => Ok(MaxBpc::_12),
14 => Ok(MaxBpc::_14),
16 => Ok(MaxBpc::_16),
_ => Err("invalid max-bpc, can be 6, 8, 10, 12, 14, 16"),
}
}
}
impl FromStr for MaxBpc {
type Err = &'static str;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s.parse::<u8>().unwrap_or_default())
}
}
impl FromStr for Layer {
type Err = &'static str;
+1
View File
@@ -109,6 +109,7 @@ impl Headless {
vrr_supported: false,
vrr_enabled: false,
logical: Some(logical_output(&output)),
max_bpc: None,
},
);
+138 -55
View File
@@ -14,7 +14,7 @@ use anyhow::{anyhow, bail, ensure, Context};
use bytemuck::cast_slice_mut;
use drm_ffi::drm_mode_modeinfo;
use libc::dev_t;
use niri_config::output::Modeline;
use niri_config::output::{MaxBpc, Modeline};
use niri_config::{Config, OutputName};
use niri_ipc::{HSyncPolarity, VSyncPolarity};
use smithay::backend::allocator::dmabuf::Dmabuf;
@@ -405,6 +405,8 @@ struct ConnectorProperties<'a> {
device: &'a DrmDevice,
connector: connector::Handle,
properties: Vec<(property::Info, property::RawValue)>,
has_change: bool,
requests: AtomicModeReq,
}
impl Tty {
@@ -676,16 +678,19 @@ impl Tty {
// Apply pending gamma changes and restore our existing gamma.
let device = self.devices.get_mut(&node).unwrap();
for (crtc, surface) in device.surfaces.iter_mut() {
if let Ok(props) =
if let Ok(mut props) =
ConnectorProperties::try_new(&device.drm, surface.connector)
{
match reset_hdr(&props) {
Ok(()) => (),
Err(err) => debug!("couldn't reset HDR properties: {err:?}"),
}
let max_bpc = self
.config
.borrow()
.outputs
.find(&surface.name)
.and_then(|o| o.max_bpc);
set_connector_properties(&mut props, max_bpc, true);
} else {
warn!("failed to get connector properties");
};
}
if let Some(ramp) = surface.pending_gamma_change.take() {
let ramp = ramp.as_deref();
@@ -1302,13 +1307,10 @@ impl Tty {
debug!("picking mode: {mode:?}");
let mut orientation = None;
if let Ok(props) = ConnectorProperties::try_new(&device.drm, connector.handle()) {
match reset_hdr(&props) {
Ok(()) => (),
Err(err) => debug!("couldn't reset HDR properties: {err:?}"),
}
if let Ok(mut props) = ConnectorProperties::try_new(&device.drm, connector.handle()) {
set_connector_properties(&mut props, config.max_bpc, true);
match get_panel_orientation(&props) {
match props.get_panel_orientation() {
Ok(x) => orientation = Some(x),
Err(err) => {
trace!("couldn't get panel orientation: {err:?}");
@@ -1316,7 +1318,7 @@ impl Tty {
}
} else {
warn!("failed to get connector properties");
};
}
let mut gamma_props = GammaProps::new(&device.drm, crtc)
.map_err(|err| debug!("couldn't get gamma properties: {err:?}"))
@@ -2194,6 +2196,15 @@ impl Tty {
OutputId::next()
});
let props = ConnectorProperties::try_new(&device.drm, connector.handle()).ok();
let max_bpc = props.as_ref().and_then(|p| p.find(c"max bpc").ok());
let max_bpc = max_bpc.and_then(|(info, value)| {
info.value_type()
.convert_value(*value)
.as_unsigned_range()
.map(|v| v as u8)
});
let ipc_output = niri_ipc::Output {
name: connector_name,
make: output_name.make.unwrap_or_else(|| "Unknown".into()),
@@ -2206,6 +2217,7 @@ impl Tty {
vrr_supported,
vrr_enabled,
logical,
max_bpc,
};
ipc_outputs.insert(id, ipc_output);
@@ -2422,6 +2434,13 @@ impl Tty {
},
};
if let Ok(mut props) = ConnectorProperties::try_new(&device.drm, surface.connector)
{
set_connector_properties(&mut props, config.max_bpc, false);
} else {
warn!("failed to get connector properties");
}
let change_mode = surface.compositor.pending_mode() != mode;
let vrr_enabled = surface.compositor.vrr_enabled();
@@ -3250,6 +3269,8 @@ impl<'a> ConnectorProperties<'a> {
device,
connector,
properties,
has_change: false,
requests: AtomicModeReq::new(),
})
}
@@ -3262,35 +3283,115 @@ impl<'a> ConnectorProperties<'a> {
Err(anyhow!("couldn't find property: {name:?}"))
}
fn get_panel_orientation(&self) -> anyhow::Result<Transform> {
let (info, value) = self.find(c"panel orientation")?;
match info.value_type().convert_value(*value) {
property::Value::Enum(Some(val)) => match val.value() {
// "Normal"
0 => Ok(Transform::Normal),
// "Upside Down"
1 => Ok(Transform::_180),
// "Left Side Up"
2 => Ok(Transform::_90),
// "Right Side Up"
3 => Ok(Transform::_270),
_ => bail!("panel orientation has invalid value: {:?}", val),
},
_ => bail!("panel orientation has wrong value type"),
}
}
fn reset_hdr(&mut self) -> anyhow::Result<()> {
const DRM_MODE_COLORIMETRY_DEFAULT: u64 = 0;
let (info, value) = self.find(c"HDR_OUTPUT_METADATA")?;
let property::ValueType::Blob = info.value_type() else {
bail!("wrong property type")
};
if *value != 0 {
self.requests
.add_raw_property(self.connector.into(), info.handle(), 0);
self.has_change = true;
}
let (info, value) = self.find(c"Colorspace")?;
let property::ValueType::Enum(_) = info.value_type() else {
bail!("wrong property type")
};
if *value != DRM_MODE_COLORIMETRY_DEFAULT {
self.requests.add_raw_property(
self.connector.into(),
info.handle(),
DRM_MODE_COLORIMETRY_DEFAULT,
);
self.has_change = true;
}
Ok(())
}
fn set_max_bpc(&mut self, max_bpc: MaxBpc) -> anyhow::Result<u64> {
let (info, value) = self.find(c"max bpc")?;
let property::ValueType::UnsignedRange(min, max) = info.value_type() else {
bail!("wrong property type")
};
let max_bpc = max_bpc.0 as u64;
if !(min..=max).contains(&max_bpc) {
bail!("max-bpc {max_bpc} outside valid range of [{min}, {max}]");
}
let property::Value::UnsignedRange(value) = info.value_type().convert_value(*value) else {
bail!("wrong property type")
};
if value != max_bpc {
self.requests.add_raw_property(
self.connector.into(),
info.handle(),
property::Value::UnsignedRange(max_bpc).into(),
);
self.has_change = true;
}
Ok(max_bpc)
}
fn commit(&mut self) -> anyhow::Result<()> {
if self.has_change {
self.device.atomic_commit(
AtomicCommitFlags::ALLOW_MODESET,
std::mem::take(&mut self.requests),
)?;
}
Ok(())
}
}
const DRM_MODE_COLORIMETRY_DEFAULT: u64 = 0;
fn reset_hdr(props: &ConnectorProperties) -> anyhow::Result<()> {
let (info, value) = props.find(c"HDR_OUTPUT_METADATA")?;
let property::ValueType::Blob = info.value_type() else {
bail!("wrong property type")
};
if *value != 0 {
props
.device
.set_property(props.connector, info.handle(), 0)
.context("error setting property")?;
fn set_connector_properties(
props: &mut ConnectorProperties,
max_bpc: Option<MaxBpc>,
reset_hdr: bool,
) {
if let Some(max_bpc) = max_bpc {
if let Err(err) = props.set_max_bpc(max_bpc) {
debug!("failed to set `max bpc` property: {err}");
}
}
let (info, value) = props.find(c"Colorspace")?;
let property::ValueType::Enum(_) = info.value_type() else {
bail!("wrong property type")
};
if *value != DRM_MODE_COLORIMETRY_DEFAULT {
props
.device
.set_property(props.connector, info.handle(), DRM_MODE_COLORIMETRY_DEFAULT)
.context("error setting property")?;
if reset_hdr {
if let Err(err) = props.reset_hdr() {
debug!("failed to set HDR properties: {err}");
}
}
Ok(())
if let Err(err) = props.commit() {
warn!("failed to atomically commit properties: {err}");
}
}
fn is_vrr_capable(device: &DrmDevice, connector: connector::Handle) -> Option<bool> {
@@ -3298,24 +3399,6 @@ fn is_vrr_capable(device: &DrmDevice, connector: connector::Handle) -> Option<bo
info.value_type().convert_value(value).as_boolean()
}
fn get_panel_orientation(props: &ConnectorProperties) -> anyhow::Result<Transform> {
let (info, value) = props.find(c"panel orientation")?;
match info.value_type().convert_value(*value) {
property::Value::Enum(Some(val)) => match val.value() {
// "Normal"
0 => Ok(Transform::Normal),
// "Upside Down"
1 => Ok(Transform::_180),
// "Left Side Up"
2 => Ok(Transform::_90),
// "Right Side Up"
3 => Ok(Transform::_270),
_ => bail!("panel orientation has invalid value: {:?}", val),
},
_ => bail!("panel orientation has wrong value type"),
}
}
pub fn set_gamma_for_crtc(
device: &DrmDevice,
crtc: crtc::Handle,
+1
View File
@@ -95,6 +95,7 @@ impl Winit {
vrr_supported: false,
vrr_enabled: false,
logical: Some(logical_output(&output)),
max_bpc: None,
},
)])));
+5
View File
@@ -568,6 +568,7 @@ fn print_output(output: Output) -> anyhow::Result<()> {
vrr_supported,
vrr_enabled,
logical,
max_bpc,
} = output;
let serial = serial.as_deref().unwrap_or("Unknown");
@@ -651,6 +652,10 @@ fn print_output(output: Output) -> anyhow::Result<()> {
println!(" Transform: {transform}");
}
if let Some(max_bpc) = max_bpc {
println!(" Max bits per channel: {max_bpc}");
}
println!(" Available modes:");
for (idx, mode) in modes.into_iter().enumerate() {
let Mode {
+2
View File
@@ -14,6 +14,7 @@ use _server_decoration::server::org_kde_kwin_server_decoration_manager::Mode as
use anyhow::{bail, ensure, Context};
use calloop::futures::Scheduler;
use niri_config::debug::PreviewRender;
use niri_config::output::MaxBpc;
use niri_config::{
Config, FloatOrInt, Key, Modifiers, OutputName, TrackLayout, WarpMouseToFocusMode,
WorkspaceReference, Xkb,
@@ -1925,6 +1926,7 @@ impl State {
None
}
}
niri_ipc::OutputAction::MaxBpc { max_bpc } => config.max_bpc = Some(MaxBpc(max_bpc)),
});
self.reload_output_config();