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
@@ -37,6 +37,11 @@ default_column_width = "full"
|
|||||||
`default_column_width = 1.0` is equivalent. `Super+M` switches the active
|
`default_column_width = 1.0` is equivalent. `Super+M` switches the active
|
||||||
workspace between scrolling and tree modes.
|
workspace between scrolling and tree modes.
|
||||||
|
|
||||||
|
Scrolling controls use `Super+R` to cycle configured width presets, `Super+C`
|
||||||
|
to center the focused column, `Super+[` to consume the focused window into an
|
||||||
|
adjacent column, and `Super+]` to expel it. `Super+Ctrl+H/J/K/L` resizes in the
|
||||||
|
corresponding direction.
|
||||||
|
|
||||||
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
|
||||||
request privileged keyboard access. Enable it only when launching a trusted
|
request privileged keyboard access. Enable it only when launching a trusted
|
||||||
|
|||||||
+68
-19
@@ -32,6 +32,8 @@ enum LayoutModeValue {
|
|||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ScrollingConfig {
|
struct ScrollingConfig {
|
||||||
default_column_width: Option<ColumnWidthValue>,
|
default_column_width: Option<ColumnWidthValue>,
|
||||||
|
#[serde(default)]
|
||||||
|
width_presets: Vec<ColumnWidthValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
@@ -51,7 +53,10 @@ pub enum ConfigError {
|
|||||||
path: PathBuf,
|
path: PathBuf,
|
||||||
source: toml::de::Error,
|
source: toml::de::Error,
|
||||||
},
|
},
|
||||||
InvalidColumnWidth(String),
|
InvalidColumnWidth {
|
||||||
|
field: &'static str,
|
||||||
|
value: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
impl fmt::Display for ConfigError {
|
impl fmt::Display for ConfigError {
|
||||||
@@ -63,9 +68,9 @@ impl fmt::Display for ConfigError {
|
|||||||
Self::Parse { path, source } => {
|
Self::Parse { path, source } => {
|
||||||
write!(formatter, "failed to parse {}: {source}", path.display())
|
write!(formatter, "failed to parse {}: {source}", path.display())
|
||||||
}
|
}
|
||||||
Self::InvalidColumnWidth(value) => write!(
|
Self::InvalidColumnWidth { field, value } => write!(
|
||||||
formatter,
|
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 {
|
match self {
|
||||||
Self::Read { source, .. } => Some(source),
|
Self::Read { source, .. } => Some(source),
|
||||||
Self::Parse { 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> {
|
pub fn default_column_width(&self) -> Result<ColumnWidth, ConfigError> {
|
||||||
match self
|
parse_column_width(
|
||||||
.scrolling
|
self.scrolling
|
||||||
.default_column_width
|
.default_column_width
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.unwrap_or(&ColumnWidthValue::Proportion(0.5))
|
.unwrap_or(&ColumnWidthValue::Proportion(0.5)),
|
||||||
{
|
"default_column_width",
|
||||||
ColumnWidthValue::Proportion(value) if value.is_finite() && *value > 0.0 => {
|
)
|
||||||
Ok(ColumnWidth::Proportion(*value))
|
}
|
||||||
}
|
|
||||||
ColumnWidthValue::Named(value) if value.eq_ignore_ascii_case("full") => {
|
pub fn width_presets(&self) -> Result<Vec<ColumnWidth>, ConfigError> {
|
||||||
Ok(ColumnWidth::Full)
|
if self.scrolling.width_presets.is_empty() {
|
||||||
}
|
return Ok(vec![
|
||||||
value => Err(ConfigError::InvalidColumnWidth(match value {
|
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::Proportion(value) => value.to_string(),
|
||||||
ColumnWidthValue::Named(value) => format!("{value:?}"),
|
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]
|
#[test]
|
||||||
fn rejects_non_positive_or_unknown_column_widths() {
|
fn rejects_non_positive_or_unknown_column_widths() {
|
||||||
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
|
let zero = parse("[scrolling]\ndefault_column_width = 0.0");
|
||||||
|
|||||||
+55
-11
@@ -71,19 +71,31 @@ impl Ferese {
|
|||||||
return FilterResult::Intercept(());
|
return FilterResult::Intercept(());
|
||||||
}
|
}
|
||||||
|
|
||||||
let direction = if modifiers.logo && !modifiers.ctrl && !modifiers.alt {
|
let direction = match symbol {
|
||||||
match symbol {
|
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
|
||||||
keysyms::KEY_h | keysyms::KEY_H => Some(Direction::Left),
|
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
|
||||||
keysyms::KEY_j | keysyms::KEY_J => Some(Direction::Down),
|
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
|
||||||
keysyms::KEY_k | keysyms::KEY_K => Some(Direction::Up),
|
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
|
||||||
keysyms::KEY_l | keysyms::KEY_L => Some(Direction::Right),
|
_ => None,
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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 {
|
if state == KeyState::Pressed {
|
||||||
data.intercepted_keys.insert(keycode);
|
data.intercepted_keys.insert(keycode);
|
||||||
|
|
||||||
@@ -147,6 +159,38 @@ impl Ferese {
|
|||||||
data.toggle_layout_mode();
|
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(())
|
FilterResult::Intercept(())
|
||||||
} else if modifiers.shift && symbol == keysyms::KEY_space {
|
} else if modifiers.shift && symbol == keysyms::KEY_space {
|
||||||
if state == KeyState::Pressed {
|
if state == KeyState::Pressed {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
display,
|
display,
|
||||||
config.layout_mode(),
|
config.layout_mode(),
|
||||||
config.default_column_width()?,
|
config.default_column_width()?,
|
||||||
|
config.width_presets()?,
|
||||||
)?;
|
)?;
|
||||||
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
backends::init(launch.backend, &mut event_loop, &mut state)?;
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ pub struct Ferese {
|
|||||||
pub window_ids: HashMap<Window, WindowId>,
|
pub window_ids: HashMap<Window, WindowId>,
|
||||||
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
pub window_geometry: HashMap<WindowId, WindowGeometry>,
|
||||||
pub focused_window: Option<WindowId>,
|
pub focused_window: Option<WindowId>,
|
||||||
|
column_width_presets: Vec<ColumnWidth>,
|
||||||
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>,
|
||||||
@@ -273,6 +274,7 @@ impl Ferese {
|
|||||||
display: Display<Self>,
|
display: Display<Self>,
|
||||||
default_layout_mode: LayoutMode,
|
default_layout_mode: LayoutMode,
|
||||||
default_column_width: ColumnWidth,
|
default_column_width: ColumnWidth,
|
||||||
|
column_width_presets: Vec<ColumnWidth>,
|
||||||
) -> Result<Self, Box<dyn Error>> {
|
) -> Result<Self, Box<dyn Error>> {
|
||||||
let display_handle = display.handle();
|
let display_handle = display.handle();
|
||||||
crate::handlers::screencopy::init_global(&display_handle);
|
crate::handlers::screencopy::init_global(&display_handle);
|
||||||
@@ -328,6 +330,7 @@ impl Ferese {
|
|||||||
window_ids: HashMap::new(),
|
window_ids: HashMap::new(),
|
||||||
window_geometry: HashMap::new(),
|
window_geometry: HashMap::new(),
|
||||||
focused_window: None,
|
focused_window: None,
|
||||||
|
column_width_presets,
|
||||||
cursor_status: CursorImageStatus::default_named(),
|
cursor_status: CursorImageStatus::default_named(),
|
||||||
cursor_theme,
|
cursor_theme,
|
||||||
named_cursors,
|
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) {
|
pub fn toggle_focused_floating(&mut self) {
|
||||||
let Some(window) = self.focused_window else {
|
let Some(window) = self.focused_window else {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -183,6 +183,45 @@ impl WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn extract_window(&mut self, window: WindowId) -> Result<bool, LayoutError> {
|
||||||
|
match self {
|
||||||
|
Self::Scrolling(layout) => {
|
||||||
|
let grouped = layout
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.find(|column| column.windows.contains(&window))
|
||||||
|
.is_some_and(|column| column.windows.len() > 1);
|
||||||
|
layout.extract_to_column(window)?;
|
||||||
|
Ok(grouped)
|
||||||
|
}
|
||||||
|
Self::Tree(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_column_width(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
presets: &[ColumnWidth],
|
||||||
|
) -> Result<bool, LayoutError> {
|
||||||
|
match self {
|
||||||
|
Self::Scrolling(layout) => layout.cycle_column_width(window, presets),
|
||||||
|
Self::Tree(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_window(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
bounds: Rect,
|
||||||
|
gaps: GapConfig,
|
||||||
|
constraints: &HashMap<WindowId, SizeConstraints>,
|
||||||
|
) -> Result<bool, LayoutError> {
|
||||||
|
match self {
|
||||||
|
Self::Scrolling(layout) => layout.center_window(window, bounds, gaps, constraints),
|
||||||
|
Self::Tree(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), LayoutError> {
|
pub fn validate(&self) -> Result<(), LayoutError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Scrolling(layout) => layout.validate(),
|
Self::Scrolling(layout) => layout.validate(),
|
||||||
@@ -472,6 +511,64 @@ impl WorkspaceSet {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn extract_window(&mut self, window: WindowId) -> Result<bool, WorkspaceError> {
|
||||||
|
if self.workspace_for_window(window) != Some(self.active)
|
||||||
|
|| self.placement(window) != Some(WindowPlacement::Tiled)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceError::InvalidState(
|
||||||
|
"extracted window is not tiled on the active workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = self.active_mut().layout.extract_window(window)?;
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cycle_column_width(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
presets: &[ColumnWidth],
|
||||||
|
) -> Result<bool, WorkspaceError> {
|
||||||
|
if self.workspace_for_window(window) != Some(self.active)
|
||||||
|
|| self.placement(window) != Some(WindowPlacement::Tiled)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceError::InvalidState(
|
||||||
|
"resized window is not tiled on the active workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = self
|
||||||
|
.active_mut()
|
||||||
|
.layout
|
||||||
|
.cycle_column_width(window, presets)?;
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_window(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
bounds: Rect,
|
||||||
|
gaps: GapConfig,
|
||||||
|
constraints: &HashMap<WindowId, SizeConstraints>,
|
||||||
|
) -> Result<bool, WorkspaceError> {
|
||||||
|
if self.workspace_for_window(window) != Some(self.active)
|
||||||
|
|| self.placement(window) != Some(WindowPlacement::Tiled)
|
||||||
|
{
|
||||||
|
return Err(WorkspaceError::InvalidState(
|
||||||
|
"centered window is not tiled on the active workspace",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let changed = self
|
||||||
|
.active_mut()
|
||||||
|
.layout
|
||||||
|
.center_window(window, bounds, gaps, constraints)?;
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
Ok(changed)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
|
pub fn ensure_numeric(&mut self, index: u32) -> Result<WorkspaceId, WorkspaceError> {
|
||||||
if index == 0 {
|
if index == 0 {
|
||||||
return Err(WorkspaceError::InvalidNumericName(index));
|
return Err(WorkspaceError::InvalidNumericName(index));
|
||||||
@@ -1179,6 +1276,34 @@ mod tests {
|
|||||||
assert!(workspaces.validate().is_ok());
|
assert!(workspaces.validate().is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_column_commands_preserve_workspace_state() {
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
for id in 1..=3 {
|
||||||
|
workspaces
|
||||||
|
.insert_window(WindowId(id), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
workspaces.stack_window(WindowId(3), WindowId(2)).unwrap();
|
||||||
|
assert!(workspaces.extract_window(WindowId(3)).unwrap());
|
||||||
|
assert!(
|
||||||
|
workspaces
|
||||||
|
.cycle_column_width(
|
||||||
|
WindowId(3),
|
||||||
|
&[ColumnWidth::Proportion(0.5), ColumnWidth::Full],
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
|
||||||
|
let _ = workspaces
|
||||||
|
.center_window(WindowId(3), bounds, GapConfig::default(), &HashMap::new())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(workspaces.active().last_focused, Some(WindowId(3)));
|
||||||
|
assert!(workspaces.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn randomized_workspace_sequences_preserve_all_invariants() {
|
fn randomized_workspace_sequences_preserve_all_invariants() {
|
||||||
let mut workspaces = WorkspaceSet::default();
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
|||||||
@@ -227,6 +227,55 @@ impl ScrollingLayout {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cycle_column_width(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
presets: &[ColumnWidth],
|
||||||
|
) -> Result<bool, LayoutError> {
|
||||||
|
let (column, _) = self
|
||||||
|
.window_location(window)
|
||||||
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
let presets = presets
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.map(normalized_width)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
if presets.is_empty() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
let current = normalized_width(self.columns[column].width);
|
||||||
|
let next = presets
|
||||||
|
.iter()
|
||||||
|
.position(|preset| *preset == current)
|
||||||
|
.map(|index| presets[(index + 1) % presets.len()])
|
||||||
|
.unwrap_or(presets[0]);
|
||||||
|
self.columns[column].width = next;
|
||||||
|
Ok(next != current)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn center_window(
|
||||||
|
&mut self,
|
||||||
|
window: WindowId,
|
||||||
|
bounds: Rect,
|
||||||
|
gaps: GapConfig,
|
||||||
|
constraints: &HashMap<WindowId, SizeConstraints>,
|
||||||
|
) -> Result<bool, LayoutError> {
|
||||||
|
let before = self.viewport_x;
|
||||||
|
let result = self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
|
||||||
|
let rect = result
|
||||||
|
.geometry
|
||||||
|
.get(&window)
|
||||||
|
.copied()
|
||||||
|
.ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
let viewport_center = bounds.x + bounds.width / 2.0;
|
||||||
|
let window_center = rect.x + rect.width / 2.0;
|
||||||
|
self.viewport_x += window_center - viewport_center;
|
||||||
|
self.geometry_with_constraints(bounds, gaps, constraints, Some(window))?;
|
||||||
|
|
||||||
|
Ok(self.viewport_x != before)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn directional_neighbor(
|
pub fn directional_neighbor(
|
||||||
&self,
|
&self,
|
||||||
window: WindowId,
|
window: WindowId,
|
||||||
@@ -472,6 +521,16 @@ impl ScrollingLayout {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn validate(&self) -> Result<(), LayoutError> {
|
pub fn validate(&self) -> Result<(), LayoutError> {
|
||||||
|
if !self.viewport_x.is_finite() || self.viewport_x < 0.0 {
|
||||||
|
return Err(LayoutError::InvalidTree(
|
||||||
|
"scrolling viewport offset is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if normalized_width(self.default_width) != self.default_width {
|
||||||
|
return Err(LayoutError::InvalidTree(
|
||||||
|
"scrolling default column width is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
if self.columns.is_empty() {
|
if self.columns.is_empty() {
|
||||||
return if self.active_column.is_none() {
|
return if self.active_column.is_none() {
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -492,6 +551,11 @@ impl ScrollingLayout {
|
|||||||
|
|
||||||
let mut windows = HashSet::new();
|
let mut windows = HashSet::new();
|
||||||
for column in &self.columns {
|
for column in &self.columns {
|
||||||
|
if normalized_width(column.width) != column.width {
|
||||||
|
return Err(LayoutError::InvalidTree(
|
||||||
|
"scrolling column width is invalid",
|
||||||
|
));
|
||||||
|
}
|
||||||
if column.windows.is_empty() {
|
if column.windows.is_empty() {
|
||||||
return Err(LayoutError::InvalidTree("scrolling column is empty"));
|
return Err(LayoutError::InvalidTree("scrolling column is empty"));
|
||||||
}
|
}
|
||||||
@@ -742,4 +806,133 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
|
assert_eq!(layout.columns()[0].heights, vec![0.6, 0.4]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn width_cycle_wraps_through_configured_presets() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
layout.insert(window(1), None).unwrap();
|
||||||
|
let presets = [
|
||||||
|
ColumnWidth::Proportion(0.5),
|
||||||
|
ColumnWidth::Proportion(2.0 / 3.0),
|
||||||
|
ColumnWidth::Full,
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
layout.columns()[0].width,
|
||||||
|
ColumnWidth::Proportion(2.0 / 3.0)
|
||||||
|
);
|
||||||
|
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
|
||||||
|
assert_eq!(layout.columns()[0].width, ColumnWidth::Full);
|
||||||
|
assert!(layout.cycle_column_width(window(1), &presets).unwrap());
|
||||||
|
assert_eq!(layout.columns()[0].width, ColumnWidth::Proportion(0.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_center_places_a_middle_column_in_the_viewport_center() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
for id in 1_u64..=4 {
|
||||||
|
layout
|
||||||
|
.insert(
|
||||||
|
window(id),
|
||||||
|
id.checked_sub(1).filter(|id| *id > 0).map(window),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let bounds = Rect::new(0.0, 0.0, 1_000.0, 800.0);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.0,
|
||||||
|
outer: 0.0,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(4)))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
layout
|
||||||
|
.center_window(window(2), bounds, gaps, &HashMap::new())
|
||||||
|
.unwrap()
|
||||||
|
);
|
||||||
|
let result = layout
|
||||||
|
.geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(window(2)))
|
||||||
|
.unwrap();
|
||||||
|
let rect = result.geometry[&window(2)];
|
||||||
|
|
||||||
|
assert_eq!(rect.x + rect.width / 2.0, 500.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn randomized_scrolling_operations_preserve_invariants() {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
let mut windows = Vec::new();
|
||||||
|
let mut next_window = 1_u64;
|
||||||
|
let mut random = 0x5c40_11ab_u64;
|
||||||
|
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
random = random
|
||||||
|
.wrapping_mul(2_862_933_555_777_941_757)
|
||||||
|
.wrapping_add(3_037_000_493);
|
||||||
|
|
||||||
|
match random % 7 {
|
||||||
|
0 if windows.len() < 48 => {
|
||||||
|
let new_window = window(next_window);
|
||||||
|
next_window += 1;
|
||||||
|
let focused = windows.get(random as usize % windows.len().max(1)).copied();
|
||||||
|
layout.insert(new_window, focused).unwrap();
|
||||||
|
windows.push(new_window);
|
||||||
|
}
|
||||||
|
1 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
layout.remove(windows.swap_remove(index)).unwrap();
|
||||||
|
}
|
||||||
|
2 if windows.len() > 1 => {
|
||||||
|
let first = random as usize % windows.len();
|
||||||
|
let mut second = random.rotate_left(17) as usize % windows.len();
|
||||||
|
if first == second {
|
||||||
|
second = (second + 1) % windows.len();
|
||||||
|
}
|
||||||
|
layout
|
||||||
|
.move_into_column(windows[first], windows[second])
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
3 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
layout.extract_to_column(windows[index]).unwrap();
|
||||||
|
}
|
||||||
|
4 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
let direction = random_direction(random.rotate_left(9));
|
||||||
|
layout.move_window(windows[index], direction).unwrap();
|
||||||
|
}
|
||||||
|
5 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
let direction = random_direction(random.rotate_left(23));
|
||||||
|
layout
|
||||||
|
.resize_window(windows[index], direction, 0.05)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
6 if !windows.is_empty() => {
|
||||||
|
let index = random as usize % windows.len();
|
||||||
|
layout.focus(windows[index]).unwrap();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
layout.validate().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
layout.window_ids().collect::<HashSet<_>>().len(),
|
||||||
|
windows.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_direction(random: u64) -> Direction {
|
||||||
|
match random % 4 {
|
||||||
|
0 => Direction::Left,
|
||||||
|
1 => Direction::Right,
|
||||||
|
2 => Direction::Up,
|
||||||
|
_ => Direction::Down,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1242,10 +1242,15 @@ Super+Enter terminal
|
|||||||
Super+Q close window
|
Super+Q close window
|
||||||
Super+H/J/K/L focus left/down/up/right
|
Super+H/J/K/L focus left/down/up/right
|
||||||
Super+Shift+H/J/K/L move left/down/up/right
|
Super+Shift+H/J/K/L move left/down/up/right
|
||||||
|
Super+Ctrl+H/J/K/L resize left/down/up/right
|
||||||
Super+1..9 switch workspace
|
Super+1..9 switch workspace
|
||||||
Super+Shift+1..9 move window to workspace
|
Super+Shift+1..9 move window to workspace
|
||||||
Super+F toggle fullscreen
|
Super+F toggle fullscreen
|
||||||
Super+M toggle scrolling/tree layout
|
Super+M toggle scrolling/tree layout
|
||||||
|
Super+R cycle scrolling column width
|
||||||
|
Super+C center scrolling column
|
||||||
|
Super+[ consume window into adjacent column
|
||||||
|
Super+] expel window from column
|
||||||
Super+Shift+Space toggle floating
|
Super+Shift+Space toggle floating
|
||||||
Super+Space launcher
|
Super+Space launcher
|
||||||
Super+Tab overview
|
Super+Tab overview
|
||||||
|
|||||||
Reference in new issue
Block a user