feat: complete scrolling layout controls
This commit is contained in:
1 parent
9d90415e55
commit
5e6c9c3d9d
8 files changed
+537
-30
No files matched your search
+68
-19
@@ -32,6 +32,8 @@ enum LayoutModeValue {
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ScrollingConfig {
|
||||
default_column_width: Option<ColumnWidthValue>,
|
||||
#[serde(default)]
|
||||
width_presets: Vec<ColumnWidthValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
@@ -51,7 +53,10 @@ pub enum ConfigError {
|
||||
path: PathBuf,
|
||||
source: toml::de::Error,
|
||||
},
|
||||
InvalidColumnWidth(String),
|
||||
InvalidColumnWidth {
|
||||
field: &'static str,
|
||||
value: String,
|
||||
},
|
||||
}
|
||||
|
||||
impl fmt::Display for ConfigError {
|
||||
@@ -63,9 +68,9 @@ impl fmt::Display for ConfigError {
|
||||
Self::Parse { path, source } => {
|
||||
write!(formatter, "failed to parse {}: {source}", path.display())
|
||||
}
|
||||
Self::InvalidColumnWidth(value) => write!(
|
||||
Self::InvalidColumnWidth { field, value } => write!(
|
||||
formatter,
|
||||
"invalid scrolling.default_column_width {value}; expected a positive number or \"full\""
|
||||
"invalid scrolling.{field} {value}; expected a positive number or \"full\""
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -76,7 +81,7 @@ impl Error for ConfigError {
|
||||
match self {
|
||||
Self::Read { source, .. } => Some(source),
|
||||
Self::Parse { source, .. } => Some(source),
|
||||
Self::InvalidColumnWidth(_) => None,
|
||||
Self::InvalidColumnWidth { .. } => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -103,23 +108,51 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
|
||||
match self
|
||||
.scrolling
|
||||
.default_column_width
|
||||
.as_ref()
|
||||
.unwrap_or(&ColumnWidthValue::Proportion(0.5))
|
||||
{
|
||||
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
|
||||
Ok(ColumnWidth::Proportion(*value))
|
||||
}
|
||||
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
|
||||
Ok(ColumnWidth::Full)
|
||||
}
|
||||
value => Err(ConfigError::InvalidColumnWidth(match value {
|
||||
parse_column_width(
|
||||
self.scrolling
|
||||
.default_column_width
|
||||
.as_ref()
|
||||
.unwrap_or(&ColumnWidthValue::Proportion(0.5)),
|
||||
"default_column_width",
|
||||
)
|
||||
}
|
||||
|
||||
pub fn width_presets(&self) -> Result<Vec<ColumnWidth>, ConfigError> {
|
||||
if self.scrolling.width_presets.is_empty() {
|
||||
return Ok(vec![
|
||||
ColumnWidth::Proportion(1.0 / 3.0),
|
||||
ColumnWidth::Proportion(0.5),
|
||||
ColumnWidth::Proportion(2.0 / 3.0),
|
||||
ColumnWidth::Full,
|
||||
]);
|
||||
}
|
||||
|
||||
self.scrolling
|
||||
.width_presets
|
||||
.iter()
|
||||
.map(|value| parse_column_width(value, "width_presets"))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_column_width(
|
||||
value: &ColumnWidthValue,
|
||||
field: &'static str,
|
||||
) -> Result<ColumnWidth, ConfigError> {
|
||||
match value {
|
||||
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
|
||||
Ok(ColumnWidth::Proportion(*value))
|
||||
}
|
||||
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
|
||||
Ok(ColumnWidth::Full)
|
||||
}
|
||||
value => Err(ConfigError::InvalidColumnWidth {
|
||||
field,
|
||||
value: match value {
|
||||
ColumnWidthValue::Proportion(value) => value.to_string(),
|
||||
ColumnWidthValue::Named(value) => format!("{value:?}"),
|
||||
})),
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,6 +198,22 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_width_presets_and_supplies_defaults() {
|
||||
let defaults = parse("");
|
||||
let configured = parse("[scrolling]\nwidth_presets = [0.5, 1.0, \"full\"]");
|
||||
|
||||
assert_eq!(defaults.width_presets().unwrap().len(), 4);
|
||||
assert_eq!(
|
||||
configured.width_presets().unwrap(),
|
||||
vec![
|
||||
ColumnWidth::Proportion(0.5),
|
||||
ColumnWidth::Proportion(1.0),
|
||||
ColumnWidth::Full
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_positive_or_unknown_column_widths() {
|
||||
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
|
||||
|
||||
+55
-11
@@ -71,19 +71,31 @@ impl Ferese {
|
||||
return FilterResult::Intercept(());
|
||||
}
|
||||
|
||||
let direction = if modifiers.logo && !modifiers.ctrl && !modifiers.alt {
|
||||
match symbol {
|
||||
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
|
||||
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
|
||||
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
|
||||
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
|
||||
_ => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
let direction = match symbol {
|
||||
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
|
||||
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
|
||||
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
|
||||
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(direction) = direction {
|
||||
if modifiers.logo
|
||||
&& modifiers.ctrl
|
||||
&& !modifiers.alt
|
||||
&& !modifiers.shift
|
||||
&& let Some(direction) = direction
|
||||
{
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
data.resize_direction(direction);
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if modifiers.logo
|
||||
&& !modifiers.ctrl
|
||||
&& !modifiers.alt
|
||||
&& let Some(direction) = direction
|
||||
{
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
|
||||
@@ -147,6 +159,38 @@ impl Ferese {
|
||||
data.toggle_layout_mode();
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if !modifiers.shift
|
||||
&& matches!(symbol, keysyms::KEY_r | keysyms::KEY_R)
|
||||
{
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
data.cycle_focused_column_width();
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if !modifiers.shift
|
||||
&& matches!(symbol, keysyms::KEY_c | keysyms::KEY_C)
|
||||
{
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
data.center_focused_column();
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if !modifiers.shift && symbol == keysyms::KEY_bracketleft {
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
data.consume_focused_window();
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if !modifiers.shift && symbol == keysyms::KEY_bracketright {
|
||||
if state == KeyState::Pressed {
|
||||
data.intercepted_keys.insert(keycode);
|
||||
data.expel_focused_window();
|
||||
}
|
||||
|
||||
FilterResult::Intercept(())
|
||||
} else if modifiers.shift && symbol == keysyms::KEY_space {
|
||||
if state == KeyState::Pressed {
|
||||
|
||||
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
display,
|
||||
config.layout_mode(),
|
||||
config.default_column_width()?,
|
||||
config.width_presets()?,
|
||||
)?;
|
||||
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ pub struct Ferese {
|
||||
pub window_ids: HashMap<Window, WindowId>,
|
||||
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
||||
pub focused_window: Option<WindowId>,
|
||||
column_width_presets: Vec<ColumnWidth>,
|
||||
pub cursor_status: CursorImageStatus,
|
||||
pub(crate) cursor_theme: xcursor::CursorTheme,
|
||||
pub(crate) named_cursors: HashMap<CursorIcon, crate::cursor::NamedCursor>,
|
||||
@@ -273,6 +274,7 @@ impl Ferese {
|
||||
display: Display<Self>,
|
||||
default_layout_mode: LayoutMode,
|
||||
default_column_width: ColumnWidth,
|
||||
column_width_presets: Vec<ColumnWidth>,
|
||||
) -> Result<Self, Box<dyn Error>> {
|
||||
let display_handle = display.handle();
|
||||
crate::handlers::screencopy::init_global(&display_handle);
|
||||
@@ -328,6 +330,7 @@ impl Ferese {
|
||||
window_ids: HashMap::new(),
|
||||
window_geometry: HashMap::new(),
|
||||
focused_window: None,
|
||||
column_width_presets,
|
||||
cursor_status: CursorImageStatus::default_named(),
|
||||
cursor_theme,
|
||||
named_cursors,
|
||||
@@ -956,6 +959,88 @@ impl Ferese {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn consume_focused_window(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
return;
|
||||
};
|
||||
if self.workspaces.active().layout.mode() != LayoutMode::Scrolling {
|
||||
return;
|
||||
}
|
||||
let target = self
|
||||
.workspaces
|
||||
.active()
|
||||
.layout
|
||||
.directional_neighbor(window, Direction::Left, bounds)
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(|| {
|
||||
self.workspaces
|
||||
.active()
|
||||
.layout
|
||||
.directional_neighbor(window, Direction::Right, bounds)
|
||||
.ok()
|
||||
.flatten()
|
||||
});
|
||||
let Some(target) = target else {
|
||||
return;
|
||||
};
|
||||
|
||||
if let Err(error) = self.workspaces.stack_window(window, target) {
|
||||
tracing::error!(%error, ?window, ?target, "failed to consume window into column");
|
||||
return;
|
||||
}
|
||||
self.relayout();
|
||||
}
|
||||
|
||||
pub fn expel_focused_window(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
|
||||
match self.workspaces.extract_window(window) {
|
||||
Ok(true) => self.relayout(),
|
||||
Ok(false) => {}
|
||||
Err(error) => tracing::error!(%error, ?window, "failed to expel window from column"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cycle_focused_column_width(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
|
||||
match self
|
||||
.workspaces
|
||||
.cycle_column_width(window, &self.column_width_presets)
|
||||
{
|
||||
Ok(true) => self.relayout(),
|
||||
Ok(false) => {}
|
||||
Err(error) => tracing::error!(%error, ?window, "failed to cycle column width"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn center_focused_column(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
return;
|
||||
};
|
||||
let constraints = self.window_constraints();
|
||||
|
||||
match self
|
||||
.workspaces
|
||||
.center_window(window, bounds, GapConfig::default(), &constraints)
|
||||
{
|
||||
Ok(true) => self.relayout(),
|
||||
Ok(false) => {}
|
||||
Err(error) => tracing::error!(%error, ?window, "failed to center column"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_focused_floating(&mut self) {
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
|
||||
Reference in new issue
Block a user