feat(layout): add tabbed window groups and scrolling columns
This commit is contained in:
1 parent
7d58eeb65d
commit
bfa78f62f2
27 files changed
+1475
-42
No files matched your search
@@ -166,6 +166,7 @@ impl Config {
|
|||||||
.and_then(|panel| panel.background_opacity),
|
.and_then(|panel| panel.background_opacity),
|
||||||
focus_effect: self.focus_effect_settings()?,
|
focus_effect: self.focus_effect_settings()?,
|
||||||
default_column_width: self.default_column_width()?,
|
default_column_width: self.default_column_width()?,
|
||||||
|
default_column_display: self.default_column_display(),
|
||||||
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
||||||
column_width_presets: self.width_presets()?,
|
column_width_presets: self.width_presets()?,
|
||||||
animations_enabled: self.animations_enabled(),
|
animations_enabled: self.animations_enabled(),
|
||||||
@@ -229,12 +230,20 @@ enum LayoutModeValue {
|
|||||||
|
|
||||||
#[derive(Debug, Default, Deserialize)]
|
#[derive(Debug, Default, Deserialize)]
|
||||||
struct ScrollingConfig {
|
struct ScrollingConfig {
|
||||||
|
default_column_display: Option<ColumnDisplayValue>,
|
||||||
default_column_width: Option<ColumnWidthValue>,
|
default_column_width: Option<ColumnWidthValue>,
|
||||||
focus_strategy: Option<FocusStrategyValue>,
|
focus_strategy: Option<FocusStrategyValue>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
width_presets: Vec<ColumnWidthValue>,
|
width_presets: Vec<ColumnWidthValue>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
enum ColumnDisplayValue {
|
||||||
|
Stacked,
|
||||||
|
Tabbed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
enum FocusStrategyValue {
|
enum FocusStrategyValue {
|
||||||
@@ -1159,13 +1168,45 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_column_display_default_is_validated() {
|
||||||
|
assert_eq!(
|
||||||
|
parse("").default_column_display(),
|
||||||
|
ferese_layout::ColumnDisplay::Stacked
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse("scrolling { default-column-display \"tabbed\"; }\n").default_column_display(),
|
||||||
|
ferese_layout::ColumnDisplay::Tabbed
|
||||||
|
);
|
||||||
|
assert!(Config::parse_source("scrolling { default-column-display \"unknown\"; }\n").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_actions_validate_arguments() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_action("join-tab", Some("left"), &HashMap::new()).unwrap(),
|
||||||
|
BindingAction::JoinTab(ferese_layout::Direction::Left)
|
||||||
|
);
|
||||||
|
assert!(parse_action("join-tab", None, &HashMap::new()).is_err());
|
||||||
|
assert!(parse_action("join-tab", Some("diagonal"), &HashMap::new()).is_err());
|
||||||
|
for (name, action) in [
|
||||||
|
("toggle-column-tabbed", BindingAction::ToggleColumnTabbed),
|
||||||
|
("leave-tab", BindingAction::LeaveTab),
|
||||||
|
("next-tab", BindingAction::CycleTab(false)),
|
||||||
|
("previous-tab", BindingAction::CycleTab(true)),
|
||||||
|
] {
|
||||||
|
assert_eq!(parse_action(name, None, &HashMap::new()).unwrap(), action);
|
||||||
|
assert!(parse_action(name, Some("extra"), &HashMap::new()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn supplies_complete_v0_bindings_and_terminal_command() {
|
fn supplies_complete_v0_bindings_and_terminal_command() {
|
||||||
let config = parse("");
|
let config = parse("");
|
||||||
let input = config.input_settings().unwrap();
|
let input = config.input_settings().unwrap();
|
||||||
let bindings = config.bindings(&input).unwrap();
|
let bindings = config.bindings(&input).unwrap();
|
||||||
|
|
||||||
assert_eq!(bindings.len(), 59);
|
assert_eq!(bindings.len(), 62);
|
||||||
for (shift, action) in [
|
for (shift, action) in [
|
||||||
(false, BindingAction::ToggleMaximized),
|
(false, BindingAction::ToggleMaximized),
|
||||||
(true, BindingAction::ToggleFullscreen),
|
(true, BindingAction::ToggleFullscreen),
|
||||||
@@ -1176,6 +1217,10 @@ mod tests {
|
|||||||
&& binding.action == action));
|
&& binding.action == action));
|
||||||
}
|
}
|
||||||
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit));
|
assert!(bindings.iter().any(|binding| binding.action == BindingAction::Exit));
|
||||||
|
assert!(bindings.iter().any(|binding| binding.modifiers.logo
|
||||||
|
&& binding.modifiers.shift
|
||||||
|
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_t)
|
||||||
|
&& binding.action == BindingAction::ToggleColumnTabbed));
|
||||||
assert!(bindings.iter().any(|binding| {
|
assert!(bindings.iter().any(|binding| {
|
||||||
binding.modifiers.logo
|
binding.modifiers.logo
|
||||||
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
|
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
|
||||||
@@ -1209,7 +1254,7 @@ mod tests {
|
|||||||
|
|
||||||
let input = replaced.input_settings().unwrap();
|
let input = replaced.input_settings().unwrap();
|
||||||
let bindings = replaced.bindings(&input).unwrap();
|
let bindings = replaced.bindings(&input).unwrap();
|
||||||
assert_eq!(bindings.len(), 59);
|
assert_eq!(bindings.len(), 62);
|
||||||
assert!(bindings.iter().any(|binding| {
|
assert!(bindings.iter().any(|binding| {
|
||||||
binding.action
|
binding.action
|
||||||
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
== BindingAction::Spawn(vec!["foot".to_owned(), "--app-id".to_owned(), "work".to_owned()].into())
|
||||||
@@ -1217,7 +1262,7 @@ mod tests {
|
|||||||
|
|
||||||
let input = unbound.input_settings().unwrap();
|
let input = unbound.input_settings().unwrap();
|
||||||
let bindings = unbound.bindings(&input).unwrap();
|
let bindings = unbound.bindings(&input).unwrap();
|
||||||
assert_eq!(bindings.len(), 58);
|
assert_eq!(bindings.len(), 61);
|
||||||
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -333,6 +333,23 @@ pub(super) fn parse_action(
|
|||||||
no_argument()?;
|
no_argument()?;
|
||||||
Ok(BindingAction::CenterColumn)
|
Ok(BindingAction::CenterColumn)
|
||||||
}
|
}
|
||||||
|
"toggle-column-tabbed" => {
|
||||||
|
no_argument()?;
|
||||||
|
Ok(BindingAction::ToggleColumnTabbed)
|
||||||
|
}
|
||||||
|
"join-tab" => Ok(BindingAction::JoinTab(parse_direction(required_argument()?)?)),
|
||||||
|
"leave-tab" => {
|
||||||
|
no_argument()?;
|
||||||
|
Ok(BindingAction::LeaveTab)
|
||||||
|
}
|
||||||
|
"next-tab" => {
|
||||||
|
no_argument()?;
|
||||||
|
Ok(BindingAction::CycleTab(false))
|
||||||
|
}
|
||||||
|
"previous-tab" => {
|
||||||
|
no_argument()?;
|
||||||
|
Ok(BindingAction::CycleTab(true))
|
||||||
|
}
|
||||||
"consume" => {
|
"consume" => {
|
||||||
no_argument()?;
|
no_argument()?;
|
||||||
Ok(BindingAction::Consume)
|
Ok(BindingAction::Consume)
|
||||||
@@ -403,6 +420,9 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
|||||||
binding("Super+C", "center-column", None),
|
binding("Super+C", "center-column", None),
|
||||||
binding("Super+[", "consume", None),
|
binding("Super+[", "consume", None),
|
||||||
binding("Super+]", "expel", None),
|
binding("Super+]", "expel", None),
|
||||||
|
binding("Super+Shift+T", "toggle-column-tabbed", None),
|
||||||
|
binding("Super+Ctrl+[", "previous-tab", None),
|
||||||
|
binding("Super+Ctrl+]", "next-tab", None),
|
||||||
binding("Super+Shift+Space", "toggle-floating", None),
|
binding("Super+Shift+Space", "toggle-floating", None),
|
||||||
binding("Super+V", "focus-floating", None),
|
binding("Super+V", "focus-floating", None),
|
||||||
binding("Super+Tab", "toggle-overview", None),
|
binding("Super+Tab", "toggle-overview", None),
|
||||||
@@ -495,6 +515,10 @@ pub enum BindingAction {
|
|||||||
ToggleLayout,
|
ToggleLayout,
|
||||||
CycleColumnWidth,
|
CycleColumnWidth,
|
||||||
CenterColumn,
|
CenterColumn,
|
||||||
|
ToggleColumnTabbed,
|
||||||
|
JoinTab(ferese_layout::Direction),
|
||||||
|
LeaveTab,
|
||||||
|
CycleTab(bool),
|
||||||
Consume,
|
Consume,
|
||||||
Expel,
|
Expel,
|
||||||
ToggleFloating,
|
ToggleFloating,
|
||||||
@@ -609,8 +633,12 @@ impl Binding {
|
|||||||
BindingAction::ToggleLayout => "Switch workspace layout".into(),
|
BindingAction::ToggleLayout => "Switch workspace layout".into(),
|
||||||
BindingAction::CycleColumnWidth => "Cycle column width".into(),
|
BindingAction::CycleColumnWidth => "Cycle column width".into(),
|
||||||
BindingAction::CenterColumn => "Center focused column".into(),
|
BindingAction::CenterColumn => "Center focused column".into(),
|
||||||
BindingAction::Consume => "Join window into column".into(),
|
BindingAction::ToggleColumnTabbed => "Toggle stacked / tabbed column".into(),
|
||||||
BindingAction::Expel => "Move window out of column".into(),
|
BindingAction::JoinTab(direction) => format!("Join tab group {direction:?}"),
|
||||||
|
BindingAction::LeaveTab => "Leave tab group".into(),
|
||||||
|
BindingAction::CycleTab(previous) => if *previous { "Previous tab" } else { "Next tab" }.into(),
|
||||||
|
BindingAction::Consume => "Join window into column or tab group".into(),
|
||||||
|
BindingAction::Expel => "Leave column or tab group".into(),
|
||||||
BindingAction::ToggleFloating => "Toggle floating window".into(),
|
BindingAction::ToggleFloating => "Toggle floating window".into(),
|
||||||
BindingAction::Media(action) => match *action {
|
BindingAction::Media(action) => match *action {
|
||||||
"next" => "Next track",
|
"next" => "Next track",
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
|
pub fn default_column_display(&self) -> ferese_layout::ColumnDisplay {
|
||||||
|
match self
|
||||||
|
.scrolling
|
||||||
|
.default_column_display
|
||||||
|
.unwrap_or(ColumnDisplayValue::Stacked)
|
||||||
|
{
|
||||||
|
ColumnDisplayValue::Stacked => ferese_layout::ColumnDisplay::Stacked,
|
||||||
|
ColumnDisplayValue::Tabbed => ferese_layout::ColumnDisplay::Tabbed,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn layout_mode(&self) -> LayoutMode {
|
pub fn layout_mode(&self) -> LayoutMode {
|
||||||
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
|
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
|
||||||
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
|
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
|
||||||
|
|||||||
@@ -550,6 +550,9 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if event.state() == ButtonState::Released && self.tab_buttons.remove(&event.button_code()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
let pointer = self.seat.get_pointer().expect("seat has a pointer");
|
||||||
let serial = SERIAL_COUNTER.next_serial();
|
let serial = SERIAL_COUNTER.next_serial();
|
||||||
if self.session_lock.active() {
|
if self.session_lock.active() {
|
||||||
@@ -580,6 +583,17 @@ impl Ferese {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if event.state() == ButtonState::Pressed
|
||||||
|
&& !pointer.is_grabbed()
|
||||||
|
&& let Some(id) = self.tab_at(pointer.current_location())
|
||||||
|
{
|
||||||
|
self.tab_buttons.insert(event.button_code());
|
||||||
|
if event.button_code() == BTN_LEFT {
|
||||||
|
self.activate_managed_window(id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Native popup_done destroys Iced's window immediately. For
|
// Native popup_done destroys Iced's window immediately. For
|
||||||
// effects-capable shell popups, release input now but defer
|
// effects-capable shell popups, release input now but defer
|
||||||
// popup_done until the compositor's whole-surface fade ends.
|
// popup_done until the compositor's whole-surface fade ends.
|
||||||
@@ -907,6 +921,9 @@ impl Ferese {
|
|||||||
self.focus_lock_surface();
|
self.focus_lock_surface();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if self.tab_at(position).is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||||
self.focus_output_at(position);
|
self.focus_output_at(position);
|
||||||
|
|
||||||
@@ -1161,6 +1178,10 @@ impl Ferese {
|
|||||||
BindingAction::ToggleLayout => self.toggle_layout_mode(),
|
BindingAction::ToggleLayout => self.toggle_layout_mode(),
|
||||||
BindingAction::CycleColumnWidth => self.cycle_focused_column_width(),
|
BindingAction::CycleColumnWidth => self.cycle_focused_column_width(),
|
||||||
BindingAction::CenterColumn => self.center_focused_column(),
|
BindingAction::CenterColumn => self.center_focused_column(),
|
||||||
|
BindingAction::ToggleColumnTabbed => self.toggle_column_tabbed(),
|
||||||
|
BindingAction::JoinTab(direction) => self.join_tab(direction),
|
||||||
|
BindingAction::LeaveTab => self.leave_tab(),
|
||||||
|
BindingAction::CycleTab(previous) => self.cycle_tab(previous),
|
||||||
BindingAction::Consume => self.consume_focused_window(),
|
BindingAction::Consume => self.consume_focused_window(),
|
||||||
BindingAction::Expel => self.expel_focused_window(),
|
BindingAction::Expel => self.expel_focused_window(),
|
||||||
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
||||||
|
|||||||
@@ -1084,6 +1084,11 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
"cycle-column-width" => self.cycle_focused_column_width(),
|
"cycle-column-width" => self.cycle_focused_column_width(),
|
||||||
"center-column" => self.center_focused_column(),
|
"center-column" => self.center_focused_column(),
|
||||||
|
"toggle-column-tabbed" => self.toggle_column_tabbed(),
|
||||||
|
"join-tab" => self.join_tab(direction_arg(args)?),
|
||||||
|
"leave-tab" => self.leave_tab(),
|
||||||
|
"next-tab" => self.cycle_tab(false),
|
||||||
|
"previous-tab" => self.cycle_tab(true),
|
||||||
"consume" => self.consume_focused_window(),
|
"consume" => self.consume_focused_window(),
|
||||||
"expel" => self.expel_focused_window(),
|
"expel" => self.expel_focused_window(),
|
||||||
"close" => self.close_focused_window(),
|
"close" => self.close_focused_window(),
|
||||||
|
|||||||
+36
-15
@@ -44,7 +44,7 @@ pub(crate) fn init_font_loader(
|
|||||||
event_loop.handle().insert_source(source, |event, _, state| {
|
event_loop.handle().insert_source(source, |event, _, state| {
|
||||||
if let channel::Event::Msg((family, font)) = event
|
if let channel::Event::Msg((family, font)) = event
|
||||||
&& state.overview.complete_font_load(&family, font)
|
&& state.overview.complete_font_load(&family, font)
|
||||||
&& state.overview.is_presenting()
|
&& (state.overview.is_presenting() || state.tab_bars.values().any(|bars| !bars.is_empty()))
|
||||||
{
|
{
|
||||||
crate::backends::direct::render_all(state);
|
crate::backends::direct::render_all(state);
|
||||||
}
|
}
|
||||||
@@ -60,20 +60,7 @@ pub(crate) fn init_font_loader(
|
|||||||
// Rescan on a font change so newly installed fonts remain usable.
|
// Rescan on a font change so newly installed fonts remain usable.
|
||||||
let mut database = fontdb::Database::new();
|
let mut database = fontdb::Database::new();
|
||||||
database.load_system_fonts();
|
database.load_system_fonts();
|
||||||
let families = [fontdb::Family::Name(&family), fontdb::Family::SansSerif];
|
let font = label_font(&database, &family);
|
||||||
let font = database
|
|
||||||
.query(&fontdb::Query {
|
|
||||||
families: &families,
|
|
||||||
..Default::default()
|
|
||||||
})
|
|
||||||
.and_then(|id| {
|
|
||||||
database.with_face_data(id, |bytes, index| {
|
|
||||||
FontVec::try_from_vec_and_index(bytes.to_vec(), index)
|
|
||||||
.ok()
|
|
||||||
.map(FontArc::new)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.flatten();
|
|
||||||
if results.send((family, font)).is_err() {
|
if results.send((family, font)).is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -85,6 +72,32 @@ pub(crate) fn init_font_loader(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fontdb's generic sans may name a font that is absent on this system.
|
||||||
|
// Resolve the requested family first, then use an installed text face.
|
||||||
|
fn label_font(database: &fontdb::Database, family: &str) -> Option<FontArc> {
|
||||||
|
let families = [
|
||||||
|
fontdb::Family::Name(family),
|
||||||
|
fontdb::Family::SansSerif,
|
||||||
|
fontdb::Family::Name("Noto Sans"),
|
||||||
|
fontdb::Family::Name("DejaVu Sans"),
|
||||||
|
fontdb::Family::Name("Liberation Sans"),
|
||||||
|
];
|
||||||
|
let preferred = database.query(&fontdb::Query {
|
||||||
|
families: &families,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
preferred
|
||||||
|
.into_iter()
|
||||||
|
.chain(database.faces().map(|face| face.id))
|
||||||
|
.find_map(|id| {
|
||||||
|
database.with_face_data(id, |bytes, index| {
|
||||||
|
let font = FontVec::try_from_vec_and_index(bytes.to_vec(), index).ok()?;
|
||||||
|
// Skip symbol-only fonts when searching the fallback faces.
|
||||||
|
(font.glyph_id('A').0 != 0 && font.glyph_id('0').0 != 0).then(|| FontArc::new(font))
|
||||||
|
})?
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
type LabelBuffer = (MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>);
|
type LabelBuffer = (MemoryRenderBuffer, smithay::utils::Size<i32, smithay::utils::Buffer>);
|
||||||
type LabelStyle = (u64, [u32; 4]);
|
type LabelStyle = (u64, [u32; 4]);
|
||||||
type OverviewStateLabel = HashMap<String, HashMap<LabelStyle, LabelBuffer>>;
|
type OverviewStateLabel = HashMap<String, HashMap<LabelStyle, LabelBuffer>>;
|
||||||
@@ -1177,6 +1190,14 @@ mod tests {
|
|||||||
font.expect("overview rendering tests require a system font")
|
font.expect("overview rendering tests require a system font")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_label_font_falls_back_to_an_installed_text_font() {
|
||||||
|
let mut database = fontdb::Database::new();
|
||||||
|
database.load_system_fonts();
|
||||||
|
assert!(label_font(&database, "ferese-missing-font-fixture").is_some());
|
||||||
|
assert!(label_font(&fontdb::Database::new(), "sans-serif").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn font_reload_keeps_labels_until_current_result_arrives() {
|
fn font_reload_keeps_labels_until_current_result_arrives() {
|
||||||
let mut overview = OverviewState::with_font_family("first".into());
|
let mut overview = OverviewState::with_font_family("first".into());
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod overview;
|
|||||||
mod resources;
|
mod resources;
|
||||||
mod scene;
|
mod scene;
|
||||||
mod shader;
|
mod shader;
|
||||||
|
mod tabs;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -329,6 +330,8 @@ pub(crate) enum OverviewChromePart {
|
|||||||
Outline,
|
Outline,
|
||||||
Strip,
|
Strip,
|
||||||
Caption,
|
Caption,
|
||||||
|
Tab,
|
||||||
|
TabActive,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
|
|||||||
@@ -122,7 +122,9 @@ pub(super) fn overview_strip_elements(
|
|||||||
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
|
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
|
||||||
{
|
{
|
||||||
cache.chrome.retain(|(id, part), _| match part {
|
cache.chrome.retain(|(id, part), _| match part {
|
||||||
OverviewChromePart::Caption => state.windows.window(ferese_layout::WindowId(*id)).is_some(),
|
OverviewChromePart::Caption | OverviewChromePart::Tab | OverviewChromePart::TabActive => {
|
||||||
|
state.windows.window(ferese_layout::WindowId(*id)).is_some()
|
||||||
|
}
|
||||||
OverviewChromePart::Card | OverviewChromePart::Outline => card_ids.contains(id),
|
OverviewChromePart::Card | OverviewChromePart::Outline => card_ids.contains(id),
|
||||||
OverviewChromePart::Strip => true,
|
OverviewChromePart::Strip => true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ impl RenderResources {
|
|||||||
|
|
||||||
pub fn remove_window(&mut self, id: WindowId) {
|
pub fn remove_window(&mut self, id: WindowId) {
|
||||||
self.windows.remove(&id);
|
self.windows.remove(&id);
|
||||||
|
for output in self.outputs.values_mut() {
|
||||||
|
output.chrome.retain(|(key, part), _| {
|
||||||
|
*key != id.0 || !matches!(part, OverviewChromePart::Tab | OverviewChromePart::TabActive)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_output(&mut self, id: OutputId) {
|
pub fn remove_output(&mut self, id: OutputId) {
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ fn scene_elements(
|
|||||||
if let Some(group) = closing.remove(&Some(id)) {
|
if let Some(group) = closing.remove(&Some(id)) {
|
||||||
elements.extend(group);
|
elements.extend(group);
|
||||||
}
|
}
|
||||||
|
elements.extend(super::tabs::tab_elements(state, renderer, output, frame, id, capture));
|
||||||
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
let constrain = rounded_visual_rect(visual, output_geometry.loc);
|
||||||
let material_surface = window
|
let material_surface = window
|
||||||
.toplevel()
|
.toplevel()
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub(super) fn tab_elements(
|
||||||
|
state: &mut Ferese,
|
||||||
|
renderer: &mut GlesRenderer,
|
||||||
|
output: &Output,
|
||||||
|
frame: &crate::state::FrameScene,
|
||||||
|
active: ferese_layout::WindowId,
|
||||||
|
capture: bool,
|
||||||
|
) -> Vec<AnimatedWindowRenderElement> {
|
||||||
|
if frame.overview.is_presenting() {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let Some(workspace) = state.workspaces.workspace_for_window(active) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(bar) = state
|
||||||
|
.visible_tab_bars(workspace)
|
||||||
|
.find(|bar| bar.group.active == active)
|
||||||
|
.cloned()
|
||||||
|
else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let Some(sample) = frame.windows.get(&active) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
if bar.rect.height <= 0.0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let content = sample.presentation.bounds.current;
|
||||||
|
let alpha = sample.presentation.alpha();
|
||||||
|
let Some(output_geometry) = state.space.output_geometry(output) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let scale = output.current_scale().fractional_scale();
|
||||||
|
let width = content.width / bar.group.windows.len() as f64;
|
||||||
|
let mut elements = Vec::new();
|
||||||
|
state.retain_overview_labels();
|
||||||
|
for (index, id) in bar.group.windows.iter().copied().enumerate() {
|
||||||
|
let rect = ferese_layout::Rect::new(
|
||||||
|
content.x + width * index as f64,
|
||||||
|
content.y - bar.rect.height,
|
||||||
|
width,
|
||||||
|
bar.rect.height,
|
||||||
|
);
|
||||||
|
let protected = capture
|
||||||
|
&& state
|
||||||
|
.windows
|
||||||
|
.window(id)
|
||||||
|
.is_none_or(|window| state.capture_protected(window));
|
||||||
|
if !protected
|
||||||
|
&& width > 20.0
|
||||||
|
&& let Some((buffer, size)) = state.overview_window_label(id, scale, width)
|
||||||
|
{
|
||||||
|
let location = Point::<i32, Physical>::from((
|
||||||
|
((rect.x - f64::from(output_geometry.loc.x) + 8.0) * scale).round() as i32,
|
||||||
|
((rect.y - f64::from(output_geometry.loc.y) + rect.height * 0.5) * scale).round() as i32 - size.h / 2,
|
||||||
|
));
|
||||||
|
if let Ok(element) = MemoryRenderBufferRenderElement::from_buffer(
|
||||||
|
renderer,
|
||||||
|
location.to_f64(),
|
||||||
|
&buffer,
|
||||||
|
Some(alpha),
|
||||||
|
Some(Rectangle::from_size((f64::from(size.w), f64::from(size.h)).into())),
|
||||||
|
Some(
|
||||||
|
(
|
||||||
|
(f64::from(size.w) / scale).round() as i32,
|
||||||
|
(f64::from(size.h) / scale).round() as i32,
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
RenderElementKind::Unspecified,
|
||||||
|
) {
|
||||||
|
let element = RescaleRenderElement::from_element(element, location, 1.0);
|
||||||
|
let element = RelocateRenderElement::from_element(element, (0, 0), Relocate::Relative);
|
||||||
|
let clip = ferese_layout::Rect::new(rect.x + 8.0, rect.y, (rect.width - 16.0).max(0.0), rect.height);
|
||||||
|
if let Some(element) =
|
||||||
|
CropRenderElement::from_element(element, scale, physical_rect(clip, output_geometry.loc, scale))
|
||||||
|
{
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if id == active {
|
||||||
|
let thickness = rect.height.min(2.0);
|
||||||
|
let underline = ferese_layout::Rect::new(rect.x, rect.y + rect.height - thickness, rect.width, thickness);
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(id.0, OverviewChromePart::TabActive),
|
||||||
|
underline,
|
||||||
|
color_with_alpha(state.theme_settings.accent_color.0, alpha),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let color = if id == active {
|
||||||
|
state.theme_settings.surface_raised_color.0
|
||||||
|
} else {
|
||||||
|
state.theme_settings.surface_base_color.0
|
||||||
|
};
|
||||||
|
if let Some(element) = overview_chrome_element(
|
||||||
|
state,
|
||||||
|
renderer,
|
||||||
|
output,
|
||||||
|
output_geometry,
|
||||||
|
scale,
|
||||||
|
(id.0, OverviewChromePart::Tab),
|
||||||
|
rect,
|
||||||
|
color_with_alpha(color, alpha),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
) {
|
||||||
|
elements.push(element.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elements
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ mod layout;
|
|||||||
mod navigation;
|
mod navigation;
|
||||||
mod outputs;
|
mod outputs;
|
||||||
mod reconciliation;
|
mod reconciliation;
|
||||||
|
mod tabs;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod window_lifecycle_tests;
|
mod window_lifecycle_tests;
|
||||||
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
||||||
@@ -455,6 +456,8 @@ pub struct Ferese {
|
|||||||
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
||||||
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
||||||
pub focused_window: Option<WindowId>,
|
pub focused_window: Option<WindowId>,
|
||||||
|
pub(crate) tab_bars: HashMap<WorkspaceId, Vec<ferese_layout::TabBar>>,
|
||||||
|
pub(crate) tab_buttons: HashSet<u32>,
|
||||||
pub(crate) focus_history: ferese_core::FocusHistory,
|
pub(crate) focus_history: ferese_core::FocusHistory,
|
||||||
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
|
pub(crate) focus_cycle: Option<ferese_core::FocusCycle>,
|
||||||
column_width_presets: Vec<ColumnWidth>,
|
column_width_presets: Vec<ColumnWidth>,
|
||||||
@@ -572,6 +575,7 @@ pub struct RuntimeConfig {
|
|||||||
pub(crate) panel_style: ferese_panel::PanelStyle,
|
pub(crate) panel_style: ferese_panel::PanelStyle,
|
||||||
pub focus_effect: crate::config::FocusEffectSettings,
|
pub focus_effect: crate::config::FocusEffectSettings,
|
||||||
pub default_column_width: ColumnWidth,
|
pub default_column_width: ColumnWidth,
|
||||||
|
pub default_column_display: ferese_layout::ColumnDisplay,
|
||||||
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
pub column_width_presets: Vec<ColumnWidth>,
|
pub column_width_presets: Vec<ColumnWidth>,
|
||||||
pub animations_enabled: bool,
|
pub animations_enabled: bool,
|
||||||
@@ -748,6 +752,8 @@ impl Ferese {
|
|||||||
workspace_slides: HashMap::new(),
|
workspace_slides: HashMap::new(),
|
||||||
workspace_slide_offsets: HashMap::new(),
|
workspace_slide_offsets: HashMap::new(),
|
||||||
focused_window: None,
|
focused_window: None,
|
||||||
|
tab_bars: HashMap::new(),
|
||||||
|
tab_buttons: HashSet::new(),
|
||||||
focus_history: Default::default(),
|
focus_history: Default::default(),
|
||||||
focus_cycle: None,
|
focus_cycle: None,
|
||||||
column_width_presets: config.column_width_presets,
|
column_width_presets: config.column_width_presets,
|
||||||
@@ -846,6 +852,9 @@ impl Ferese {
|
|||||||
state.screenshot_worker = Some(screenshot.worker);
|
state.screenshot_worker = Some(screenshot.worker);
|
||||||
state._ipc_socket = Some(screenshot._guard);
|
state._ipc_socket = Some(screenshot._guard);
|
||||||
|
|
||||||
|
state
|
||||||
|
.workspaces
|
||||||
|
.set_default_column_display(config.default_column_display);
|
||||||
Ok(state)
|
Ok(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -951,6 +960,8 @@ impl Ferese {
|
|||||||
// Hidden layouts are resolved independently of presented geometry.
|
// Hidden layouts are resolved independently of presented geometry.
|
||||||
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
self.workspace_preview_revision = self.workspace_preview_revision.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
self.workspaces
|
||||||
|
.set_default_column_display(config.default_column_display);
|
||||||
self.gap_config = config.gap_config;
|
self.gap_config = config.gap_config;
|
||||||
self.input_settings = config.input_settings;
|
self.input_settings = config.input_settings;
|
||||||
if bindings_changed || input_changed {
|
if bindings_changed || input_changed {
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ impl Ferese {
|
|||||||
if self.overview_strip_at(position).is_some() {
|
if self.overview_strip_at(position).is_some() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
if self.tab_at(position).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||||
.map(|(_, surface, origin)| (surface, origin))
|
.map(|(_, surface, origin)| (surface, origin))
|
||||||
.or_else(|| self.window_surface_under(position))
|
.or_else(|| self.window_surface_under(position))
|
||||||
|
|||||||
@@ -86,7 +86,11 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn workspace_layout_bounds(&self, bounds: Rect, layout: &WorkspaceLayout) -> Rect {
|
pub(super) fn workspace_layout_bounds(&self, bounds: Rect, layout: &WorkspaceLayout) -> Rect {
|
||||||
let outer = if self.gap_config.smart && layout.window_ids().count() == 1 {
|
let visible = match layout {
|
||||||
|
WorkspaceLayout::Tree(tree) => tree.visible_window_count(),
|
||||||
|
WorkspaceLayout::Scrolling(layout) => layout.visible_window_count(),
|
||||||
|
};
|
||||||
|
let outer = if self.gap_config.smart && visible == 1 {
|
||||||
0.
|
0.
|
||||||
} else {
|
} else {
|
||||||
self.gap_config.outer
|
self.gap_config.outer
|
||||||
@@ -118,6 +122,12 @@ impl Ferese {
|
|||||||
|
|
||||||
pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool {
|
pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool {
|
||||||
let mut changed = false;
|
let mut changed = false;
|
||||||
|
let inactive_tabs: HashSet<_> = self
|
||||||
|
.tab_bars
|
||||||
|
.values()
|
||||||
|
.flatten()
|
||||||
|
.flat_map(|bar| bar.group.windows.iter().copied().filter(|id| *id != bar.group.active))
|
||||||
|
.collect();
|
||||||
let windows = self
|
let windows = self
|
||||||
.windows
|
.windows
|
||||||
.ids()
|
.ids()
|
||||||
@@ -136,6 +146,11 @@ impl Ferese {
|
|||||||
geometry.settle_presentation();
|
geometry.settle_presentation();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if inactive_tabs.contains(&id) {
|
||||||
|
// An inactive client must not hold its column's resize or viewport.
|
||||||
|
self.windows.clear_transaction(&id);
|
||||||
|
self.windows.update(id, |record| record.coupled_width = None);
|
||||||
|
}
|
||||||
self.space.unmap_elem(&window);
|
self.space.unmap_elem(&window);
|
||||||
}
|
}
|
||||||
self.refresh_idle_inhibition();
|
self.refresh_idle_inhibition();
|
||||||
@@ -195,6 +210,7 @@ impl Ferese {
|
|||||||
self.refresh_input_capture_zones();
|
self.refresh_input_capture_zones();
|
||||||
let visible_workspaces = self.visible_workspace_ids();
|
let visible_workspaces = self.visible_workspace_ids();
|
||||||
self.reconcile_workspaces(&visible_workspaces);
|
self.reconcile_workspaces(&visible_workspaces);
|
||||||
|
self.tab_bars.retain(|id, _| visible_workspaces.contains(id));
|
||||||
if self.session_lock.active() {
|
if self.session_lock.active() {
|
||||||
self.configure_lock_surfaces();
|
self.configure_lock_surfaces();
|
||||||
}
|
}
|
||||||
@@ -228,7 +244,14 @@ impl Ferese {
|
|||||||
.iter()
|
.iter()
|
||||||
.find(|column| column.windows.contains(&id))
|
.find(|column| column.windows.contains(&id))
|
||||||
.and_then(|column| {
|
.and_then(|column| {
|
||||||
column.windows.iter().find_map(|sibling| {
|
column
|
||||||
|
.windows
|
||||||
|
.iter()
|
||||||
|
.filter(|sibling| {
|
||||||
|
column.display != ferese_layout::ColumnDisplay::Tabbed
|
||||||
|
|| **sibling == column.windows[column.active]
|
||||||
|
})
|
||||||
|
.find_map(|sibling| {
|
||||||
self.windows.transaction(sibling).and_then(|transaction| {
|
self.windows.transaction(sibling).and_then(|transaction| {
|
||||||
transaction.column_width().map(|(source, _)| source)
|
transaction.column_width().map(|(source, _)| source)
|
||||||
})
|
})
|
||||||
@@ -255,7 +278,10 @@ impl Ferese {
|
|||||||
// to every row; sibling configure holds already release the
|
// to every row; sibling configure holds already release the
|
||||||
// whole column together, including partial client commits.
|
// whole column together, including partial client commits.
|
||||||
let mut rows = column.windows.iter().filter(|id| {
|
let mut rows = column.windows.iter().filter(|id| {
|
||||||
self.windows
|
(column.display != ferese_layout::ColumnDisplay::Tabbed
|
||||||
|
|| **id == column.windows[column.active])
|
||||||
|
&& self
|
||||||
|
.windows
|
||||||
.geometry(id)
|
.geometry(id)
|
||||||
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
||||||
});
|
});
|
||||||
@@ -345,6 +371,7 @@ impl Ferese {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
self.tab_bars.insert(workspace_id, layout.tab_bars);
|
||||||
let viewport_target = workspace.layout.viewport_x();
|
let viewport_target = workspace.layout.viewport_x();
|
||||||
let viewport_motion = viewport_target.map(|target| {
|
let viewport_motion = viewport_target.map(|target| {
|
||||||
let viewport = self
|
let viewport = self
|
||||||
@@ -380,6 +407,10 @@ impl Ferese {
|
|||||||
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
|
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
|
||||||
let placement = self.workspaces.placement(*id);
|
let placement = self.workspaces.placement(*id);
|
||||||
let is_floating = matches!(placement, Some(WindowPlacement::Floating { .. }));
|
let is_floating = matches!(placement, Some(WindowPlacement::Floating { .. }));
|
||||||
|
// Maximization cannot make an inactive tab visible again.
|
||||||
|
if !is_floating && workspace_fullscreen != Some(*id) && !layout.geometry.contains_key(id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let rect = if workspace_fullscreen == Some(*id) {
|
let rect = if workspace_fullscreen == Some(*id) {
|
||||||
fullscreen_bounds
|
fullscreen_bounds
|
||||||
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
||||||
|
|||||||
@@ -270,13 +270,30 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn consume_focused_window(&mut self) {
|
pub fn consume_focused_window(&mut self) {
|
||||||
|
if self.workspaces.active().fullscreen.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(window) = self.focused_window else {
|
let Some(window) = self.focused_window else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let Some(bounds) = self.output_bounds() else {
|
let Some(bounds) = self.output_bounds() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if self.workspaces.active().layout.mode() != LayoutMode::Scrolling {
|
if self.workspaces.active().layout.mode() == LayoutMode::Tree {
|
||||||
|
if let Some(direction) = [Direction::Left, Direction::Right, Direction::Up, Direction::Down]
|
||||||
|
.into_iter()
|
||||||
|
.find(|direction| {
|
||||||
|
self.workspaces
|
||||||
|
.active()
|
||||||
|
.layout
|
||||||
|
.directional_neighbor(window, *direction, bounds)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.is_some()
|
||||||
|
})
|
||||||
|
{
|
||||||
|
self.join_tab(direction);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let target = self
|
let target = self
|
||||||
@@ -306,6 +323,9 @@ impl Ferese {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn expel_focused_window(&mut self) {
|
pub fn expel_focused_window(&mut self) {
|
||||||
|
if self.workspaces.active().fullscreen.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(window) = self.focused_window else {
|
let Some(window) = self.focused_window else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,144 @@
|
|||||||
|
use super::*;
|
||||||
|
|
||||||
|
impl Ferese {
|
||||||
|
pub(crate) fn join_tab(&mut self, direction: Direction) {
|
||||||
|
let Some(window) = self.focused_window else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(bounds) = self.output_bounds() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let workspace = self.workspaces.active();
|
||||||
|
if workspace.fullscreen.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(Some(target)) = workspace.layout.directional_neighbor(window, direction, bounds) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout {
|
||||||
|
// Joining rows already in the same column changes their display only.
|
||||||
|
let same_column = layout
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.any(|c| c.windows.contains(&window) && c.windows.contains(&target));
|
||||||
|
if same_column {
|
||||||
|
let _ = layout.set_column_display(window, ferese_layout::ColumnDisplay::Tabbed);
|
||||||
|
self.relayout();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Err(error) = self.workspaces.stack_window(window, target) {
|
||||||
|
tracing::warn!(%error, "failed to join tab group");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout {
|
||||||
|
let _ = layout.set_column_display(window, ferese_layout::ColumnDisplay::Tabbed);
|
||||||
|
}
|
||||||
|
self.relayout();
|
||||||
|
self.restore_keyboard_focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn toggle_column_tabbed(&mut self) {
|
||||||
|
let Some(window) = self.focused_window else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.workspaces.active().fullscreen.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &mut self.workspaces.active_mut().layout else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if layout.toggle_column_tabbed(window).is_ok() {
|
||||||
|
self.relayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn leave_tab(&mut self) {
|
||||||
|
if self.workspaces.active().fullscreen.is_none()
|
||||||
|
&& self
|
||||||
|
.focused_window
|
||||||
|
.and_then(|id| self.workspaces.active().layout.tab_group(id))
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
self.expel_focused_window();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cycle_tab(&mut self, previous: bool) {
|
||||||
|
let Some(window) = self.focused_window else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if self.workspaces.active().fullscreen.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(target) = self.workspaces.active().layout.adjacent_tab(window, previous) {
|
||||||
|
self.activate_managed_window(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn visible_tab_bars(&self, workspace: WorkspaceId) -> impl Iterator<Item = &ferese_layout::TabBar> {
|
||||||
|
self.tab_bars.get(&workspace).into_iter().flatten().filter(move |bar| {
|
||||||
|
self.workspaces
|
||||||
|
.workspace(workspace)
|
||||||
|
.is_some_and(|ws| ws.fullscreen.is_none())
|
||||||
|
&& self.windows.record(bar.group.active).is_some_and(|w| !w.maximized)
|
||||||
|
&& self
|
||||||
|
.windows
|
||||||
|
.window(bar.group.active)
|
||||||
|
.is_some_and(|w| self.space.element_location(w).is_some())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn tab_at(&self, position: Point<f64, Logical>) -> Option<WindowId> {
|
||||||
|
if self.session_lock.active()
|
||||||
|
|| self.overview.is_active()
|
||||||
|
|| self.input_capture.active()
|
||||||
|
|| self
|
||||||
|
.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||||
|
.is_some()
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let workspace = self.workspace_under_pointer(position)?;
|
||||||
|
// Floating windows and popups in front of the group keep their input.
|
||||||
|
if self.window_surface_under(position).is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
self.visible_tab_bars(workspace).find_map(|bar| {
|
||||||
|
let content = self.presented_window_rect(bar.group.active)?;
|
||||||
|
let rect = Rect::new(content.x, content.y - bar.rect.height, content.width, bar.rect.height);
|
||||||
|
tab_hit(&bar.group.windows, rect, position.x, position.y)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn tab_hit(windows: &[WindowId], rect: Rect, x: f64, y: f64) -> Option<WindowId> {
|
||||||
|
if windows.is_empty()
|
||||||
|
|| rect.width <= 0.0
|
||||||
|
|| x < rect.x
|
||||||
|
|| x >= rect.x + rect.width
|
||||||
|
|| y < rect.y
|
||||||
|
|| y >= rect.y + rect.height
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
windows
|
||||||
|
.get(((x - rect.x) / rect.width * windows.len() as f64).floor() as usize)
|
||||||
|
.copied()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
#[test]
|
||||||
|
fn tab_hit_uses_half_open_rectangles_at_fractional_coordinates() {
|
||||||
|
let windows = [WindowId(1), WindowId(2), WindowId(3)];
|
||||||
|
let rect = Rect::new(10.5, 20.5, 301., 30.);
|
||||||
|
assert_eq!(tab_hit(&windows, rect, 10.5, 20.5), Some(windows[0]));
|
||||||
|
assert_eq!(tab_hit(&windows, rect, 111., 25.), Some(windows[1]));
|
||||||
|
assert_eq!(tab_hit(&windows, rect, 311.49, 25.), Some(windows[2]));
|
||||||
|
for (x, y) in [(10., 25.), (311.5, 25.), (20., 50.5)] {
|
||||||
|
assert_eq!(tab_hit(&windows, rect, x, y), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -976,3 +976,100 @@ fn reloading_mode_bindings_cancels_the_modes_focus_preview() {
|
|||||||
key(&mut state, 64, false);
|
key(&mut state, 64, false);
|
||||||
assert!(state.intercepted_keys.is_empty());
|
assert!(state.intercepted_keys.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tree_tabs_join_switch_leave_and_hide_inactive_maximized_windows() {
|
||||||
|
if !crate::startup_tests::private_runtime(
|
||||||
|
"state::window_lifecycle_tests::tree_tabs_join_switch_leave_and_hide_inactive_maximized_windows",
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (mut events, mut state, _output) = fixture();
|
||||||
|
state.toggle_layout_mode();
|
||||||
|
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||||
|
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||||
|
let a = state.windows.ids()[&first];
|
||||||
|
let b = state.windows.ids()[&second];
|
||||||
|
state.focused_window = Some(b);
|
||||||
|
state.consume_focused_window();
|
||||||
|
let workspace = state.workspaces.active_id();
|
||||||
|
assert_eq!(state.tab_bars[&workspace].len(), 1);
|
||||||
|
assert!(state.space.element_location(&first).is_none());
|
||||||
|
assert!(state.space.element_location(&second).is_some());
|
||||||
|
let bar = &state.tab_bars[&workspace][0];
|
||||||
|
let header = bar.rect;
|
||||||
|
let content = state.windows.geometry(&b).unwrap().visual.target;
|
||||||
|
assert_eq!(content.y, header.y + header.height);
|
||||||
|
let hit = Point::from((header.x + header.width * 0.25, header.y + header.height * 0.5));
|
||||||
|
assert_eq!(state.tab_at(hit), Some(a));
|
||||||
|
assert!(state.surface_under(hit).is_none());
|
||||||
|
state.cycle_tab(false);
|
||||||
|
assert_eq!(state.focused_window, Some(a));
|
||||||
|
assert!(state.space.element_location(&first).is_some());
|
||||||
|
assert!(state.space.element_location(&second).is_none());
|
||||||
|
state.toggle_focused_maximized();
|
||||||
|
assert_eq!(state.visible_tab_bars(workspace).count(), 0);
|
||||||
|
state.cycle_tab(true);
|
||||||
|
assert_eq!(state.focused_window, Some(b));
|
||||||
|
assert!(
|
||||||
|
state.space.element_location(&first).is_none(),
|
||||||
|
"inactive maximized tab must remain hidden"
|
||||||
|
);
|
||||||
|
assert_eq!(state.visible_tab_bars(workspace).count(), 1);
|
||||||
|
state.toggle_focused_fullscreen();
|
||||||
|
let tree = state.workspaces.active().layout.clone();
|
||||||
|
state.leave_tab();
|
||||||
|
state.expel_focused_window();
|
||||||
|
state.cycle_tab(false);
|
||||||
|
assert_eq!(state.workspaces.active().layout, tree);
|
||||||
|
assert_eq!(state.visible_tab_bars(workspace).count(), 0);
|
||||||
|
state.toggle_focused_fullscreen();
|
||||||
|
state.leave_tab();
|
||||||
|
assert!(state.tab_bars[&workspace].is_empty());
|
||||||
|
assert!(state.space.element_location(&first).is_some());
|
||||||
|
assert!(state.space.element_location(&second).is_some());
|
||||||
|
assert!(state.workspaces.active().layout.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds() {
|
||||||
|
if !crate::startup_tests::private_runtime(
|
||||||
|
"state::window_lifecycle_tests::scrolling_tabs_switch_with_directional_focus_and_drop_hidden_resize_holds",
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (mut events, mut state, _output) = fixture();
|
||||||
|
let (first, _first_wire) = window(&mut state, &mut events, 0xff112233);
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
let (second, _second_wire) = window(&mut state, &mut events, 0xff445566);
|
||||||
|
state.consume_focused_window();
|
||||||
|
let (third, _third_wire) = window(&mut state, &mut events, 0xff778899);
|
||||||
|
state.consume_focused_window();
|
||||||
|
let a = state.windows.ids()[&first];
|
||||||
|
let b = state.windows.ids()[&second];
|
||||||
|
let c = state.windows.ids()[&third];
|
||||||
|
let workspace = state.workspaces.active_id();
|
||||||
|
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b, c]);
|
||||||
|
assert_eq!(state.space.elements().count(), 1);
|
||||||
|
let position = state.workspaces.active().layout.viewport_x();
|
||||||
|
state.windows.set_transaction(
|
||||||
|
c,
|
||||||
|
crate::resize_transaction::ResizeTransaction::new(SERIAL_COUNTER.next_serial(), state.presentation_now()),
|
||||||
|
);
|
||||||
|
state.focus_direction(Direction::Up);
|
||||||
|
assert_eq!(state.focused_window, Some(b));
|
||||||
|
assert!(state.windows.transaction(&c).is_none());
|
||||||
|
assert!(state.space.element_location(&third).is_none());
|
||||||
|
assert!(state.space.element_location(&second).is_some());
|
||||||
|
assert_eq!(state.workspaces.active().layout.viewport_x(), position);
|
||||||
|
state.focus_direction(Direction::Down);
|
||||||
|
assert_eq!(state.focused_window, Some(c));
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
assert_eq!(state.space.elements().count(), 3);
|
||||||
|
assert!(state.tab_bars[&workspace].is_empty());
|
||||||
|
state.toggle_column_tabbed();
|
||||||
|
assert_eq!(state.space.elements().count(), 1);
|
||||||
|
state.leave_tab();
|
||||||
|
assert_eq!(state.space.elements().count(), 2);
|
||||||
|
assert_eq!(state.tab_bars[&workspace][0].group.windows, [a, b]);
|
||||||
|
}
|
||||||
@@ -50,6 +50,13 @@ impl WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn with_column_display(mut self, display: ferese_layout::ColumnDisplay) -> Self {
|
||||||
|
if let Self::Scrolling(layout) = &mut self {
|
||||||
|
layout.set_default_display(display);
|
||||||
|
}
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn mode(&self) -> LayoutMode {
|
pub fn mode(&self) -> LayoutMode {
|
||||||
match self {
|
match self {
|
||||||
Self::Scrolling(_) => LayoutMode::Scrolling,
|
Self::Scrolling(_) => LayoutMode::Scrolling,
|
||||||
@@ -111,6 +118,20 @@ impl WorkspaceLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn tab_group(&self, window: WindowId) -> Option<ferese_layout::TabGroup> {
|
||||||
|
match self {
|
||||||
|
Self::Tree(tree) => tree.tab_group(window),
|
||||||
|
Self::Scrolling(layout) => layout.tab_group(window),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
|
||||||
|
match self {
|
||||||
|
Self::Tree(tree) => tree.adjacent_tab(window, previous),
|
||||||
|
Self::Scrolling(layout) => layout.adjacent_tab(window, previous),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
|
pub fn stack_window(&mut self, window: WindowId, target: WindowId) -> Result<(), LayoutError> {
|
||||||
match self {
|
match self {
|
||||||
Self::Scrolling(layout) => layout.move_into_column(window, target),
|
Self::Scrolling(layout) => layout.move_into_column(window, target),
|
||||||
@@ -158,7 +179,32 @@ impl WorkspaceLayout {
|
|||||||
constraints,
|
constraints,
|
||||||
focused.filter(|window| layout.contains(*window)),
|
focused.filter(|window| layout.contains(*window)),
|
||||||
),
|
),
|
||||||
Self::Tree(layout) => layout.geometry_with_constraints(bounds, gaps, constraints, focused),
|
Self::Tree(layout) => {
|
||||||
|
let groups = layout.tab_groups();
|
||||||
|
if groups.is_empty() {
|
||||||
|
return layout.geometry_with_constraints(bounds, gaps, constraints, focused);
|
||||||
|
}
|
||||||
|
const TAB_HEIGHT: f64 = ferese_layout::TAB_BAR_HEIGHT;
|
||||||
|
let mut constraints = constraints.clone();
|
||||||
|
for group in &groups {
|
||||||
|
for id in &group.windows {
|
||||||
|
let constraint = constraints.entry(*id).or_default();
|
||||||
|
constraint.min_height += TAB_HEIGHT;
|
||||||
|
constraint.max_height = constraint.max_height.map(|height| height + TAB_HEIGHT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut result = layout.geometry_with_constraints(bounds, gaps, &constraints, focused)?;
|
||||||
|
for group in groups {
|
||||||
|
if let Some(rect) = result.geometry.get_mut(&group.active) {
|
||||||
|
let height = TAB_HEIGHT.min((rect.height - 1.0).max(0.0));
|
||||||
|
let header = Rect::new(rect.x, rect.y, rect.width, height);
|
||||||
|
rect.y += height;
|
||||||
|
rect.height -= height;
|
||||||
|
result.tab_bars.push(ferese_layout::TabBar { group, rect: header });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +252,7 @@ impl WorkspaceLayout {
|
|||||||
layout.extract_to_column(window)?;
|
layout.extract_to_column(window)?;
|
||||||
Ok(grouped)
|
Ok(grouped)
|
||||||
}
|
}
|
||||||
Self::Tree(_) => Ok(false),
|
Self::Tree(layout) => layout.leave_tab_group(window),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +391,7 @@ pub struct WorkspaceSet {
|
|||||||
default_layout_mode: LayoutMode,
|
default_layout_mode: LayoutMode,
|
||||||
default_column_width: ColumnWidth,
|
default_column_width: ColumnWidth,
|
||||||
scrolling_focus_strategy: ViewportFocusStrategy,
|
scrolling_focus_strategy: ViewportFocusStrategy,
|
||||||
|
default_column_display: ferese_layout::ColumnDisplay,
|
||||||
next_id: u64,
|
next_id: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -382,10 +429,21 @@ impl WorkspaceSet {
|
|||||||
default_layout_mode,
|
default_layout_mode,
|
||||||
default_column_width,
|
default_column_width,
|
||||||
scrolling_focus_strategy,
|
scrolling_focus_strategy,
|
||||||
|
default_column_display: ferese_layout::ColumnDisplay::Stacked,
|
||||||
next_id: 2,
|
next_id: 2,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Applies to future columns, including those in future workspaces.
|
||||||
|
pub fn set_default_column_display(&mut self, display: ferese_layout::ColumnDisplay) {
|
||||||
|
self.default_column_display = display;
|
||||||
|
for workspace in self.workspaces.values_mut() {
|
||||||
|
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
|
||||||
|
layout.set_default_display(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn reconfigure_live(
|
pub fn reconfigure_live(
|
||||||
&mut self,
|
&mut self,
|
||||||
mode: LayoutMode,
|
mode: LayoutMode,
|
||||||
@@ -427,6 +485,7 @@ impl WorkspaceSet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
scrolling.set_default_width(width);
|
scrolling.set_default_width(width);
|
||||||
|
scrolling.set_default_display(self.default_column_display);
|
||||||
if strategy != self.scrolling_focus_strategy {
|
if strategy != self.scrolling_focus_strategy {
|
||||||
scrolling.set_focus_strategy(strategy);
|
scrolling.set_focus_strategy(strategy);
|
||||||
}
|
}
|
||||||
@@ -508,11 +567,15 @@ impl WorkspaceSet {
|
|||||||
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
|
pub fn set_active_layout_mode(&mut self, mode: LayoutMode, bounds: Rect) -> Result<bool, WorkspaceError> {
|
||||||
let default_column_width = self.default_column_width;
|
let default_column_width = self.default_column_width;
|
||||||
let focus_strategy = self.scrolling_focus_strategy;
|
let focus_strategy = self.scrolling_focus_strategy;
|
||||||
|
let display = self.default_column_display;
|
||||||
let workspace = self.active_mut();
|
let workspace = self.active_mut();
|
||||||
let focused = tiled_focus(workspace);
|
let focused = tiled_focus(workspace);
|
||||||
let changed = workspace
|
let changed = workspace
|
||||||
.layout
|
.layout
|
||||||
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
|
.set_mode(mode, bounds, focused, default_column_width, focus_strategy)?;
|
||||||
|
if let WorkspaceLayout::Scrolling(layout) = &mut workspace.layout {
|
||||||
|
layout.set_default_display(display);
|
||||||
|
}
|
||||||
|
|
||||||
debug_assert!(self.validate().is_ok());
|
debug_assert!(self.validate().is_ok());
|
||||||
Ok(changed)
|
Ok(changed)
|
||||||
@@ -689,7 +752,8 @@ impl WorkspaceSet {
|
|||||||
self.default_layout_mode,
|
self.default_layout_mode,
|
||||||
self.default_column_width,
|
self.default_column_width,
|
||||||
self.scrolling_focus_strategy,
|
self.scrolling_focus_strategy,
|
||||||
),
|
)
|
||||||
|
.with_column_display(self.default_column_display),
|
||||||
floating: Vec::new(),
|
floating: Vec::new(),
|
||||||
last_focused: None,
|
last_focused: None,
|
||||||
fullscreen: None,
|
fullscreen: None,
|
||||||
@@ -1690,3 +1754,97 @@ mod tests {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tab_geometry_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_layout_conversion_preserves_default_column_display() {
|
||||||
|
use ferese_layout::ColumnDisplay;
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
|
||||||
|
for mode in [LayoutMode::Tree, LayoutMode::Scrolling] {
|
||||||
|
workspaces
|
||||||
|
.reconfigure_live(
|
||||||
|
mode,
|
||||||
|
workspaces.default_column_width,
|
||||||
|
workspaces.scrolling_focus_strategy,
|
||||||
|
&HashMap::new(),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_column_display_only_changes_future_columns_and_reaches_new_workspaces() {
|
||||||
|
use ferese_layout::ColumnDisplay;
|
||||||
|
let mut workspaces = WorkspaceSet::default();
|
||||||
|
workspaces.insert_window(WindowId(1), Axis::Horizontal, 0.5).unwrap();
|
||||||
|
workspaces.set_default_column_display(ColumnDisplay::Tabbed);
|
||||||
|
workspaces.insert_window(WindowId(2), Axis::Horizontal, 0.5).unwrap();
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(layout.columns()[0].display, ColumnDisplay::Stacked);
|
||||||
|
assert_eq!(layout.columns()[1].display, ColumnDisplay::Tabbed);
|
||||||
|
let next = workspaces.create_workspace();
|
||||||
|
workspaces.activate(next).unwrap();
|
||||||
|
workspaces.insert_window(WindowId(3), Axis::Horizontal, 0.5).unwrap();
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
|
||||||
|
workspaces.set_default_column_display(ColumnDisplay::Stacked);
|
||||||
|
workspaces.insert_window(WindowId(4), Axis::Horizontal, 0.5).unwrap();
|
||||||
|
let WorkspaceLayout::Scrolling(layout) = &workspaces.active().layout else {
|
||||||
|
panic!()
|
||||||
|
};
|
||||||
|
assert_eq!(layout.columns()[0].display, ColumnDisplay::Tabbed);
|
||||||
|
assert_eq!(layout.columns()[1].display, ColumnDisplay::Stacked);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_header_reserves_space_and_disappears_after_leaving() {
|
||||||
|
let mut layout = WorkspaceLayout::Tree(LayoutTree::default());
|
||||||
|
layout.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
|
||||||
|
layout
|
||||||
|
.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
layout.stack_window(WindowId(2), WindowId(1)).unwrap();
|
||||||
|
for height in [12., 200., 600.] {
|
||||||
|
let bounds = Rect::new(0., 0., 800., height);
|
||||||
|
let gaps = GapConfig {
|
||||||
|
inner: 0.,
|
||||||
|
outer: 0.,
|
||||||
|
smart: false,
|
||||||
|
};
|
||||||
|
let result = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(2)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.tab_bars.len(), 1);
|
||||||
|
let header = result.tab_bars[0].rect;
|
||||||
|
let content = result.geometry[&WindowId(2)];
|
||||||
|
assert_eq!(header.y + header.height, content.y);
|
||||||
|
assert_eq!(content.height + header.height, height);
|
||||||
|
assert!(content.height > 0.0);
|
||||||
|
assert!(!result.geometry.contains_key(&WindowId(1)));
|
||||||
|
}
|
||||||
|
layout.extract_window(WindowId(2)).unwrap();
|
||||||
|
let result = layout
|
||||||
|
.resolve_geometry_with_constraints(
|
||||||
|
Rect::new(0., 0., 800., 600.),
|
||||||
|
GapConfig::default(),
|
||||||
|
&HashMap::new(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.tab_bars.is_empty());
|
||||||
|
assert_eq!(result.geometry.len(), 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ use std::fmt;
|
|||||||
|
|
||||||
mod scrolling;
|
mod scrolling;
|
||||||
|
|
||||||
pub use scrolling::{Column, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
|
pub use scrolling::{Column, ColumnDisplay, ColumnWidth, ScrollingLayout, ViewportFocusStrategy, ViewportTarget};
|
||||||
|
|
||||||
|
pub const TAB_BAR_HEIGHT: f64 = 30.0;
|
||||||
|
|
||||||
const MIN_SPLIT_RATIO: f64 = 0.05;
|
const MIN_SPLIT_RATIO: f64 = 0.05;
|
||||||
const MAX_SPLIT_RATIO: f64 = 0.95;
|
const MAX_SPLIT_RATIO: f64 = 0.95;
|
||||||
@@ -15,6 +17,18 @@ pub struct WindowId(pub u64);
|
|||||||
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
pub struct NodeId(u64);
|
pub struct NodeId(u64);
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct TabGroup {
|
||||||
|
pub windows: Vec<WindowId>,
|
||||||
|
pub active: WindowId,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
pub struct TabBar {
|
||||||
|
pub group: TabGroup,
|
||||||
|
pub rect: Rect,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
pub enum Axis {
|
pub enum Axis {
|
||||||
Horizontal,
|
Horizontal,
|
||||||
@@ -108,6 +122,7 @@ pub struct ConstraintWarning {
|
|||||||
pub struct LayoutResult {
|
pub struct LayoutResult {
|
||||||
pub geometry: HashMap<WindowId, Rect>,
|
pub geometry: HashMap<WindowId, Rect>,
|
||||||
pub warnings: Vec<ConstraintWarning>,
|
pub warnings: Vec<ConstraintWarning>,
|
||||||
|
pub tab_bars: Vec<TabBar>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for GapConfig {
|
impl Default for GapConfig {
|
||||||
@@ -165,6 +180,18 @@ impl LayoutTree {
|
|||||||
self.windows.keys().copied()
|
self.windows.keys().copied()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn visible_window_count(&self) -> usize {
|
||||||
|
fn count(tree: &LayoutTree, node: NodeId) -> usize {
|
||||||
|
match tree.nodes.get(&node) {
|
||||||
|
Some(Node::Window(_)) => 1,
|
||||||
|
Some(Node::Split { first, second, .. }) => count(tree, *first) + count(tree, *second),
|
||||||
|
Some(Node::Stack { children, active }) => children.get(*active).map_or(0, |child| count(tree, *child)),
|
||||||
|
None => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.root.map_or(0, |root| count(self, root))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn preferred_window(&self) -> Option<WindowId> {
|
pub fn preferred_window(&self) -> Option<WindowId> {
|
||||||
let leaf = self.first_window(self.root?).ok()?;
|
let leaf = self.first_window(self.root?).ok()?;
|
||||||
match self.nodes.get(&leaf)? {
|
match self.nodes.get(&leaf)? {
|
||||||
@@ -211,6 +238,8 @@ impl LayoutTree {
|
|||||||
.ok_or(LayoutError::UnknownWindow(focused))?,
|
.ok_or(LayoutError::UnknownWindow(focused))?,
|
||||||
None => self.first_window(root)?,
|
None => self.first_window(root)?,
|
||||||
};
|
};
|
||||||
|
// A new split belongs beside the whole tab group, not inside one tab.
|
||||||
|
let target = self.tab_parent(target).unwrap_or(target);
|
||||||
let old_parent = self.parents.get(&target).copied();
|
let old_parent = self.parents.get(&target).copied();
|
||||||
let window_node = self.insert_node(Node::Window(window));
|
let window_node = self.insert_node(Node::Window(window));
|
||||||
self.windows.insert(window, window_node);
|
self.windows.insert(window, window_node);
|
||||||
@@ -307,6 +336,13 @@ impl LayoutTree {
|
|||||||
return Err(LayoutError::UnknownWindow(window));
|
return Err(LayoutError::UnknownWindow(window));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self
|
||||||
|
.tab_group(window)
|
||||||
|
.is_some_and(|group| group.windows.contains(&target))
|
||||||
|
{
|
||||||
|
return self.activate_window(window);
|
||||||
|
}
|
||||||
|
|
||||||
self.remove(window)?;
|
self.remove(window)?;
|
||||||
|
|
||||||
let target_node = self
|
let target_node = self
|
||||||
@@ -316,6 +352,17 @@ impl LayoutTree {
|
|||||||
.ok_or(LayoutError::UnknownWindow(target))?;
|
.ok_or(LayoutError::UnknownWindow(target))?;
|
||||||
let old_parent = self.parents.get(&target_node).copied();
|
let old_parent = self.parents.get(&target_node).copied();
|
||||||
let window_node = self.insert_node(Node::Window(window));
|
let window_node = self.insert_node(Node::Window(window));
|
||||||
|
if let Some(parent) = self.tab_parent(target_node) {
|
||||||
|
let Some(Node::Stack { children, active }) = self.nodes.get_mut(&parent) else {
|
||||||
|
unreachable!()
|
||||||
|
};
|
||||||
|
children.push(window_node);
|
||||||
|
*active = children.len() - 1;
|
||||||
|
self.windows.insert(window, window_node);
|
||||||
|
self.parents.insert(window_node, parent);
|
||||||
|
debug_assert!(self.validate().is_ok());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
let stack = self.insert_node(Node::Stack {
|
let stack = self.insert_node(Node::Stack {
|
||||||
children: vec![target_node, window_node],
|
children: vec![target_node, window_node],
|
||||||
active: 1,
|
active: 1,
|
||||||
@@ -336,6 +383,73 @@ impl LayoutTree {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn tab_parent(&self, leaf: NodeId) -> Option<NodeId> {
|
||||||
|
let parent = *self.parents.get(&leaf)?;
|
||||||
|
match self.nodes.get(&parent)? {
|
||||||
|
Node::Stack { children, .. }
|
||||||
|
if children
|
||||||
|
.iter()
|
||||||
|
.all(|id| matches!(self.nodes.get(id), Some(Node::Window(_)))) =>
|
||||||
|
{
|
||||||
|
Some(parent)
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_at(&self, node: NodeId) -> Option<TabGroup> {
|
||||||
|
let Node::Stack { children, active } = self.nodes.get(&node)? else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let windows: Option<Vec<_>> = children
|
||||||
|
.iter()
|
||||||
|
.map(|id| match self.nodes.get(id) {
|
||||||
|
Some(Node::Window(window)) => Some(*window),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let windows = windows?;
|
||||||
|
Some(TabGroup {
|
||||||
|
active: *windows.get(*active)?,
|
||||||
|
windows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tab_group(&self, window: WindowId) -> Option<TabGroup> {
|
||||||
|
self.group_at(*self.parents.get(self.windows.get(&window)?)?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tab_groups(&self) -> Vec<TabGroup> {
|
||||||
|
let mut groups: Vec<_> = self.nodes.keys().filter_map(|id| self.group_at(*id)).collect();
|
||||||
|
groups.sort_by_key(|group| group.windows[0].0);
|
||||||
|
groups
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
|
||||||
|
let group = self.tab_group(window)?;
|
||||||
|
let index = group.windows.iter().position(|id| *id == window)?;
|
||||||
|
let step = if previous { group.windows.len() - 1 } else { 1 };
|
||||||
|
Some(group.windows[(index + step) % group.windows.len()])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Leave the group in place and split the departing window to its right.
|
||||||
|
pub fn leave_tab_group(&mut self, window: WindowId) -> Result<bool, LayoutError> {
|
||||||
|
if !self.contains(window) {
|
||||||
|
return Err(LayoutError::UnknownWindow(window));
|
||||||
|
}
|
||||||
|
let Some(group) = self.tab_group(window) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let target = group
|
||||||
|
.windows
|
||||||
|
.into_iter()
|
||||||
|
.find(|id| *id != window)
|
||||||
|
.expect("group has at least two tabs");
|
||||||
|
self.remove(window)?;
|
||||||
|
self.insert(window, Some(target), Axis::Horizontal, 0.5)?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
pub fn activate_window(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
||||||
let mut child = self
|
let mut child = self
|
||||||
.windows
|
.windows
|
||||||
@@ -1576,3 +1690,71 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tab_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn nested() -> LayoutTree {
|
||||||
|
let mut tree = LayoutTree::default();
|
||||||
|
tree.insert(WindowId(1), None, Axis::Horizontal, 0.5).unwrap();
|
||||||
|
tree.insert(WindowId(2), Some(WindowId(1)), Axis::Horizontal, 0.6)
|
||||||
|
.unwrap();
|
||||||
|
tree.insert(WindowId(3), Some(WindowId(1)), Axis::Vertical, 0.4)
|
||||||
|
.unwrap();
|
||||||
|
tree.insert(WindowId(4), Some(WindowId(3)), Axis::Horizontal, 0.5)
|
||||||
|
.unwrap();
|
||||||
|
tree
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joining_and_leaving_tabs_preserve_neighboring_splits() {
|
||||||
|
let mut tree = nested();
|
||||||
|
let bounds = Rect::new(0., 0., 1000., 800.);
|
||||||
|
let neighbor = tree.geometry(bounds).unwrap()[&WindowId(2)];
|
||||||
|
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
|
||||||
|
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
tree.tab_group(WindowId(1)).unwrap().windows,
|
||||||
|
[WindowId(3), WindowId(4), WindowId(1)]
|
||||||
|
);
|
||||||
|
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
|
||||||
|
assert_eq!(tree.visible_window_count(), 2);
|
||||||
|
let before = tree.clone();
|
||||||
|
tree.stack_window(WindowId(1), WindowId(4)).unwrap();
|
||||||
|
assert_eq!(tree, before, "joining the same group must not reorder it");
|
||||||
|
assert_eq!(tree.adjacent_tab(WindowId(1), false), Some(WindowId(3)));
|
||||||
|
assert_eq!(tree.adjacent_tab(WindowId(3), true), Some(WindowId(1)));
|
||||||
|
assert!(tree.leave_tab_group(WindowId(1)).unwrap());
|
||||||
|
assert_eq!(tree.geometry(bounds).unwrap()[&WindowId(2)], neighbor);
|
||||||
|
assert_eq!(tree.tab_group(WindowId(4)).unwrap().windows, [WindowId(3), WindowId(4)]);
|
||||||
|
assert!(tree.tab_group(WindowId(1)).is_none());
|
||||||
|
assert!(!tree.leave_tab_group(WindowId(1)).unwrap());
|
||||||
|
tree.remove(WindowId(4)).unwrap();
|
||||||
|
assert!(tree.tab_groups().is_empty());
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn opening_beside_a_group_and_removing_active_tabs_keep_all_members_reachable() {
|
||||||
|
let mut tree = nested();
|
||||||
|
tree.stack_window(WindowId(4), WindowId(3)).unwrap();
|
||||||
|
tree.stack_window(WindowId(1), WindowId(3)).unwrap();
|
||||||
|
tree.insert(WindowId(5), Some(WindowId(1)), Axis::Vertical, 0.3)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(tree.tab_groups().len(), 1);
|
||||||
|
let bounds = Rect::new(0., 0., 1000., 800.);
|
||||||
|
for id in [WindowId(3), WindowId(4), WindowId(1)] {
|
||||||
|
tree.activate_window(id).unwrap();
|
||||||
|
let geometry = tree.geometry(bounds).unwrap();
|
||||||
|
assert!(geometry.contains_key(&WindowId(5)));
|
||||||
|
assert!(geometry.contains_key(&id));
|
||||||
|
assert_eq!(geometry.len(), 3);
|
||||||
|
}
|
||||||
|
tree.remove(WindowId(1)).unwrap();
|
||||||
|
assert_eq!(tree.tab_group(WindowId(3)).unwrap().active, WindowId(4));
|
||||||
|
tree.remove(WindowId(3)).unwrap();
|
||||||
|
assert!(tree.tab_groups().is_empty());
|
||||||
|
assert!(tree.validate().is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,13 @@ const MAX_COLUMN_WIDTH: f64 = 2.0;
|
|||||||
const MIN_ROW_HEIGHT: f64 = 0.05;
|
const MIN_ROW_HEIGHT: f64 = 0.05;
|
||||||
const REVEAL_EPSILON: f64 = 0.5;
|
const REVEAL_EPSILON: f64 = 0.5;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub enum ColumnDisplay {
|
||||||
|
#[default]
|
||||||
|
Stacked,
|
||||||
|
Tabbed,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
pub enum ColumnWidth {
|
pub enum ColumnWidth {
|
||||||
Proportion(f64),
|
Proportion(f64),
|
||||||
@@ -60,6 +67,7 @@ pub struct Column {
|
|||||||
pub active: usize,
|
pub active: usize,
|
||||||
pub width: ColumnWidth,
|
pub width: ColumnWidth,
|
||||||
pub heights: Vec<f64>,
|
pub heights: Vec<f64>,
|
||||||
|
pub display: ColumnDisplay,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Column {
|
impl Column {
|
||||||
@@ -69,6 +77,7 @@ impl Column {
|
|||||||
active: 0,
|
active: 0,
|
||||||
width: normalized_width(width),
|
width: normalized_width(width),
|
||||||
heights: vec![1.0],
|
heights: vec![1.0],
|
||||||
|
display: ColumnDisplay::Stacked,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +200,7 @@ pub struct ScrollingLayout {
|
|||||||
active_column: Option<usize>,
|
active_column: Option<usize>,
|
||||||
viewport_x: f64,
|
viewport_x: f64,
|
||||||
default_width: ColumnWidth,
|
default_width: ColumnWidth,
|
||||||
|
default_display: ColumnDisplay,
|
||||||
focus_strategy: ViewportFocusStrategy,
|
focus_strategy: ViewportFocusStrategy,
|
||||||
pending_viewport: Option<PendingViewportRequest>,
|
pending_viewport: Option<PendingViewportRequest>,
|
||||||
last_column_sizing: Option<ColumnSizing>,
|
last_column_sizing: Option<ColumnSizing>,
|
||||||
@@ -205,6 +215,7 @@ impl Default for ScrollingLayout {
|
|||||||
active_column: None,
|
active_column: None,
|
||||||
viewport_x: 0.0,
|
viewport_x: 0.0,
|
||||||
default_width: ColumnWidth::default(),
|
default_width: ColumnWidth::default(),
|
||||||
|
default_display: ColumnDisplay::default(),
|
||||||
focus_strategy: ViewportFocusStrategy::Minimal,
|
focus_strategy: ViewportFocusStrategy::Minimal,
|
||||||
pending_viewport: None,
|
pending_viewport: None,
|
||||||
last_column_sizing: None,
|
last_column_sizing: None,
|
||||||
@@ -237,6 +248,55 @@ impl ScrollingLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_default_display(&mut self, display: ColumnDisplay) {
|
||||||
|
self.default_display = display;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_column_display(&mut self, window: WindowId, display: ColumnDisplay) -> Result<(), LayoutError> {
|
||||||
|
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
self.columns[index].display = display;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle_column_tabbed(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
||||||
|
let (index, _) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
let column = &mut self.columns[index];
|
||||||
|
column.display = match column.display {
|
||||||
|
ColumnDisplay::Stacked => ColumnDisplay::Tabbed,
|
||||||
|
ColumnDisplay::Tabbed => ColumnDisplay::Stacked,
|
||||||
|
};
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tab_group(&self, window: WindowId) -> Option<crate::TabGroup> {
|
||||||
|
let (index, _) = self.window_location(window)?;
|
||||||
|
let column = &self.columns[index];
|
||||||
|
(column.display == ColumnDisplay::Tabbed && column.windows.len() > 1).then(|| crate::TabGroup {
|
||||||
|
windows: column.windows.clone(),
|
||||||
|
active: column.windows[column.active],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adjacent_tab(&self, window: WindowId, previous: bool) -> Option<WindowId> {
|
||||||
|
let group = self.tab_group(window)?;
|
||||||
|
let index = group.windows.iter().position(|id| *id == window)?;
|
||||||
|
let step = if previous { group.windows.len() - 1 } else { 1 };
|
||||||
|
Some(group.windows[(index + step) % group.windows.len()])
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn visible_window_count(&self) -> usize {
|
||||||
|
self.columns
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
if c.display == ColumnDisplay::Tabbed {
|
||||||
|
1
|
||||||
|
} else {
|
||||||
|
c.windows.len()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sum()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn set_default_width(&mut self, width: ColumnWidth) {
|
pub fn set_default_width(&mut self, width: ColumnWidth) {
|
||||||
self.default_width = normalized_width(width);
|
self.default_width = normalized_width(width);
|
||||||
}
|
}
|
||||||
@@ -333,7 +393,9 @@ impl ScrollingLayout {
|
|||||||
.and_then(|focused| self.window_location(focused))
|
.and_then(|focused| self.window_location(focused))
|
||||||
.map(|(column, _)| column + 1)
|
.map(|(column, _)| column + 1)
|
||||||
.unwrap_or(self.columns.len());
|
.unwrap_or(self.columns.len());
|
||||||
self.columns.insert(index, Column::new(window, self.default_width));
|
let mut column = Column::new(window, self.default_width);
|
||||||
|
column.display = self.default_display;
|
||||||
|
self.columns.insert(index, column);
|
||||||
self.active_column = Some(index);
|
self.active_column = Some(index);
|
||||||
self.request_reveal((self.columns.len() > 1).then_some(window));
|
self.request_reveal((self.columns.len() > 1).then_some(window));
|
||||||
|
|
||||||
@@ -399,7 +461,14 @@ impl ScrollingLayout {
|
|||||||
|
|
||||||
/// An explicit selection must reveal even a window already focused by hover.
|
/// An explicit selection must reveal even a window already focused by hover.
|
||||||
pub fn focus_and_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
pub fn focus_and_reveal(&mut self, window: WindowId) -> Result<(), LayoutError> {
|
||||||
|
let (column, row) = self.window_location(window).ok_or(LayoutError::UnknownWindow(window))?;
|
||||||
|
let switching_tab = self.active_column == Some(column)
|
||||||
|
&& self.columns[column].display == ColumnDisplay::Tabbed
|
||||||
|
&& self.columns[column].active != row;
|
||||||
self.focus_without_reveal(window)?;
|
self.focus_without_reveal(window)?;
|
||||||
|
if switching_tab {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
self.request_focus(Some(PendingFocusRequest::Reveal(window)));
|
self.request_focus(Some(PendingFocusRequest::Reveal(window)));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -407,6 +476,12 @@ impl ScrollingLayout {
|
|||||||
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
|
pub fn slide_focus_from(&mut self, previous: WindowId, focused: WindowId) -> Result<(), LayoutError> {
|
||||||
self.window_location(previous)
|
self.window_location(previous)
|
||||||
.ok_or(LayoutError::UnknownWindow(previous))?;
|
.ok_or(LayoutError::UnknownWindow(previous))?;
|
||||||
|
if self
|
||||||
|
.tab_group(previous)
|
||||||
|
.is_some_and(|group| group.windows.contains(&focused))
|
||||||
|
{
|
||||||
|
return self.focus_without_reveal(focused);
|
||||||
|
}
|
||||||
if self.focus_strategy == ViewportFocusStrategy::Minimal {
|
if self.focus_strategy == ViewportFocusStrategy::Minimal {
|
||||||
return self.focus_and_reveal(focused);
|
return self.focus_and_reveal(focused);
|
||||||
}
|
}
|
||||||
@@ -450,7 +525,9 @@ impl ScrollingLayout {
|
|||||||
let width = self.columns[source].width;
|
let width = self.columns[source].width;
|
||||||
self.remove(window)?;
|
self.remove(window)?;
|
||||||
let insertion = (source + 1).min(self.columns.len());
|
let insertion = (source + 1).min(self.columns.len());
|
||||||
self.columns.insert(insertion, Column::new(window, width));
|
let mut column = Column::new(window, width);
|
||||||
|
column.display = self.default_display;
|
||||||
|
self.columns.insert(insertion, column);
|
||||||
self.active_column = Some(insertion);
|
self.active_column = Some(insertion);
|
||||||
self.request_reveal(self.active_window());
|
self.request_reveal(self.active_window());
|
||||||
|
|
||||||
@@ -598,6 +675,10 @@ impl ScrollingLayout {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.columns[column].display == ColumnDisplay::Tabbed && matches!(direction, Direction::Up | Direction::Down)
|
||||||
|
{
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
let changed = match direction {
|
let changed = match direction {
|
||||||
Direction::Left | Direction::Right => {
|
Direction::Left | Direction::Right => {
|
||||||
let current = match normalized_width(self.columns[column].width) {
|
let current = match normalized_width(self.columns[column].width) {
|
||||||
@@ -646,7 +727,7 @@ impl ScrollingLayout {
|
|||||||
return Ok(LayoutResult::default());
|
return Ok(LayoutResult::default());
|
||||||
}
|
}
|
||||||
|
|
||||||
let window_count = self.window_ids().count();
|
let window_count = self.visible_window_count();
|
||||||
let inner = finite_nonnegative(gaps.inner);
|
let inner = finite_nonnegative(gaps.inner);
|
||||||
let outer = if gaps.smart && window_count == 1 {
|
let outer = if gaps.smart && window_count == 1 {
|
||||||
0.0
|
0.0
|
||||||
@@ -751,6 +832,51 @@ impl ScrollingLayout {
|
|||||||
let mut result = LayoutResult::default();
|
let mut result = LayoutResult::default();
|
||||||
for (column_index, column) in self.columns.iter().enumerate() {
|
for (column_index, column) in self.columns.iter().enumerate() {
|
||||||
let (column_x, column_width) = column_positions[column_index];
|
let (column_x, column_width) = column_positions[column_index];
|
||||||
|
if column.display == ColumnDisplay::Tabbed {
|
||||||
|
let window = column.windows[column.active];
|
||||||
|
let header = if column.windows.len() > 1 {
|
||||||
|
crate::TAB_BAR_HEIGHT.min((viewport_height - 1.0).max(0.0))
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let mut rect = Rect::new(
|
||||||
|
bounds.x + outer + column_x - self.viewport_x,
|
||||||
|
bounds.y + outer + header,
|
||||||
|
column_width,
|
||||||
|
viewport_height - header,
|
||||||
|
);
|
||||||
|
// All tabs use one size, even when their client constraints differ.
|
||||||
|
// Keep the column allocation stable and honor compatible maximums.
|
||||||
|
let mut common = SizeConstraints::default();
|
||||||
|
for id in &column.windows {
|
||||||
|
let c = normalized_constraints(constraints.get(id).copied().unwrap_or_default());
|
||||||
|
common.min_width = common.min_width.max(c.min_width);
|
||||||
|
common.min_height = common.min_height.max(c.min_height);
|
||||||
|
common.max_width = match (common.max_width, c.max_width) {
|
||||||
|
(Some(a), Some(b)) => Some(a.min(b)),
|
||||||
|
(a, b) => a.or(b),
|
||||||
|
};
|
||||||
|
common.max_height = match (common.max_height, c.max_height) {
|
||||||
|
(Some(a), Some(b)) => Some(a.min(b)),
|
||||||
|
(a, b) => a.or(b),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let common = normalized_constraints(common);
|
||||||
|
record_minimum_warnings(window, rect, common, &mut result.warnings);
|
||||||
|
apply_maximums(window, &mut rect, common, &mut result.warnings);
|
||||||
|
if header > 0.0 {
|
||||||
|
result.tab_bars.push(crate::TabBar {
|
||||||
|
group: crate::TabGroup {
|
||||||
|
windows: column.windows.clone(),
|
||||||
|
active: window,
|
||||||
|
},
|
||||||
|
rect: Rect::new(rect.x, rect.y - header, rect.width, header),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
result.geometry.insert(window, rect);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(0.0);
|
let available_height = (viewport_height - inner * (column.windows.len().saturating_sub(1) as f64)).max(0.0);
|
||||||
let row_constraints = column
|
let row_constraints = column
|
||||||
.windows
|
.windows
|
||||||
@@ -875,7 +1001,9 @@ impl ScrollingLayout {
|
|||||||
let width = self.columns[source].width;
|
let width = self.columns[source].width;
|
||||||
self.remove(window)?;
|
self.remove(window)?;
|
||||||
let insertion = insertion.min(self.columns.len());
|
let insertion = insertion.min(self.columns.len());
|
||||||
self.columns.insert(insertion, Column::new(window, width));
|
let mut column = Column::new(window, width);
|
||||||
|
column.display = self.default_display;
|
||||||
|
self.columns.insert(insertion, column);
|
||||||
self.active_column = Some(insertion);
|
self.active_column = Some(insertion);
|
||||||
self.request_reveal(self.active_window());
|
self.request_reveal(self.active_window());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -2522,3 +2650,151 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tabbed_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn fixture() -> ScrollingLayout {
|
||||||
|
let mut layout = ScrollingLayout::default();
|
||||||
|
for id in 1..=4 {
|
||||||
|
layout.insert(WindowId(id), layout.active_window()).unwrap();
|
||||||
|
}
|
||||||
|
layout.move_into_column(WindowId(3), WindowId(2)).unwrap();
|
||||||
|
layout.move_into_column(WindowId(4), WindowId(3)).unwrap();
|
||||||
|
layout
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggling_tabs_restores_weights_and_keeps_viewport_and_column_widths() {
|
||||||
|
for strategy in [
|
||||||
|
ViewportFocusStrategy::Minimal,
|
||||||
|
ViewportFocusStrategy::Center,
|
||||||
|
ViewportFocusStrategy::Paged,
|
||||||
|
] {
|
||||||
|
let mut layout = fixture();
|
||||||
|
layout.set_focus_strategy(strategy);
|
||||||
|
layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap();
|
||||||
|
let bounds = Rect::new(0., 0., 1000., 800.);
|
||||||
|
let gaps = GapConfig::default();
|
||||||
|
let before = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
|
||||||
|
.unwrap();
|
||||||
|
let widths = layout.allocated_widths.clone();
|
||||||
|
let weights = layout.columns[1].heights.clone();
|
||||||
|
layout.toggle_column_tabbed(WindowId(4)).unwrap();
|
||||||
|
let tabbed = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
|
||||||
|
.unwrap();
|
||||||
|
let viewport = layout.viewport_x();
|
||||||
|
assert_eq!(tabbed.geometry.len(), 2);
|
||||||
|
assert_eq!(tabbed.tab_bars.len(), 1);
|
||||||
|
assert_eq!(layout.allocated_widths, widths);
|
||||||
|
let rect = tabbed.geometry[&WindowId(4)];
|
||||||
|
for id in [WindowId(2), WindowId(3), WindowId(4)] {
|
||||||
|
layout.focus_and_reveal(id).unwrap();
|
||||||
|
let result = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(id))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(result.geometry[&id], rect);
|
||||||
|
assert_eq!(layout.viewport_x(), viewport);
|
||||||
|
assert_eq!(layout.allocated_widths, widths);
|
||||||
|
}
|
||||||
|
assert!(!layout.resize_window(WindowId(3), Direction::Up, 0.1).unwrap());
|
||||||
|
layout.toggle_column_tabbed(WindowId(4)).unwrap();
|
||||||
|
let after = layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, gaps, &HashMap::new(), Some(WindowId(4)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(layout.columns[1].heights, weights);
|
||||||
|
assert_eq!(before.geometry, after.geometry);
|
||||||
|
assert!(after.tab_bars.is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_navigation_join_leave_and_single_column_policy_remain_consistent() {
|
||||||
|
let mut layout = fixture();
|
||||||
|
layout.set_default_display(ColumnDisplay::Tabbed);
|
||||||
|
layout.toggle_column_tabbed(WindowId(4)).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
layout.directional_neighbor(WindowId(3), Direction::Up).unwrap(),
|
||||||
|
Some(WindowId(2))
|
||||||
|
);
|
||||||
|
assert_eq!(layout.adjacent_tab(WindowId(4), false), Some(WindowId(2)));
|
||||||
|
assert_eq!(layout.adjacent_tab(WindowId(2), true), Some(WindowId(4)));
|
||||||
|
layout.move_into_column(WindowId(1), WindowId(2)).unwrap();
|
||||||
|
assert_eq!(layout.visible_window_count(), 1);
|
||||||
|
assert_eq!(layout.columns[0].display, ColumnDisplay::Tabbed);
|
||||||
|
layout.extract_to_column(WindowId(1)).unwrap();
|
||||||
|
assert_eq!(layout.columns.len(), 2);
|
||||||
|
assert!(layout.columns.iter().all(|c| c.display == ColumnDisplay::Tabbed));
|
||||||
|
assert!(layout.tab_group(WindowId(1)).is_none());
|
||||||
|
layout.remove(WindowId(4)).unwrap();
|
||||||
|
layout.remove(WindowId(3)).unwrap();
|
||||||
|
assert!(layout.tab_group(WindowId(2)).is_none());
|
||||||
|
layout.insert(WindowId(5), Some(WindowId(2))).unwrap();
|
||||||
|
assert_eq!(layout.columns[1].display, ColumnDisplay::Tabbed);
|
||||||
|
assert!(layout.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_swipe_does_not_recenter_a_manually_offset_viewport() {
|
||||||
|
let mut layout = fixture();
|
||||||
|
layout.set_focus_strategy(ViewportFocusStrategy::Center);
|
||||||
|
layout.toggle_column_tabbed(WindowId(4)).unwrap();
|
||||||
|
let bounds = Rect::new(0., 0., 1000., 800.);
|
||||||
|
layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(4)))
|
||||||
|
.unwrap();
|
||||||
|
layout.viewport_x += 33.0;
|
||||||
|
let position = layout.viewport_x;
|
||||||
|
layout.focus_and_reveal(WindowId(3)).unwrap();
|
||||||
|
layout.slide_focus_from(WindowId(4), WindowId(3)).unwrap();
|
||||||
|
layout
|
||||||
|
.resolve_geometry_with_constraints(bounds, GapConfig::default(), &HashMap::new(), Some(WindowId(3)))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(layout.viewport_x, position);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn constraints_do_not_change_tab_geometry_on_selection() {
|
||||||
|
let mut layout = fixture();
|
||||||
|
layout.toggle_column_tabbed(WindowId(4)).unwrap();
|
||||||
|
let constraints = HashMap::from([
|
||||||
|
(
|
||||||
|
WindowId(2),
|
||||||
|
SizeConstraints {
|
||||||
|
min_width: 400.,
|
||||||
|
max_height: Some(300.),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
WindowId(3),
|
||||||
|
SizeConstraints {
|
||||||
|
max_width: Some(450.),
|
||||||
|
min_height: 200.,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
let mut previous = None;
|
||||||
|
for id in [WindowId(2), WindowId(3), WindowId(4)] {
|
||||||
|
let result = layout
|
||||||
|
.resolve_geometry_with_constraints(
|
||||||
|
Rect::new(0., 0., 1000., 800.),
|
||||||
|
GapConfig::default(),
|
||||||
|
&constraints,
|
||||||
|
Some(id),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let rect = result.geometry[&id];
|
||||||
|
assert_eq!(rect.width, 450.);
|
||||||
|
assert_eq!(rect.height, 300.);
|
||||||
|
if let Some(previous) = previous {
|
||||||
|
assert_eq!(rect, previous);
|
||||||
|
}
|
||||||
|
previous = Some(rect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
-3
@@ -113,6 +113,7 @@ numbers must be finite. Omit optional values rather than writing `null`, use
|
|||||||
|
|
||||||
| `scrolling` key | Type / values | Default | Meaning |
|
| `scrolling` key | Type / values | Default | Meaning |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
|
| `default-column-display` | `"stacked"`, `"tabbed"` | `"stacked"` | Display mode of new columns; reload preserves existing column choices |
|
||||||
| `default-column-width` | proportion > 0; or `"full"` | `0.5` | Width of new columns |
|
| `default-column-width` | proportion > 0; or `"full"` | `0.5` | Width of new columns |
|
||||||
| `focus-strategy` | `"minimal"`, `"center_on_focus"`, `"paged"` | `"minimal"` | Viewport movement on focus |
|
| `focus-strategy` | `"minimal"`, `"center_on_focus"`, `"paged"` | `"minimal"` | Viewport movement on focus |
|
||||||
| `width-presets` | array of widths | `[0.3333333333333333, 0.5, 0.6666666666666666, "full"]` | Super+R cycle; empty uses defaults |
|
| `width-presets` | array of widths | `[0.3333333333333333, 0.5, 0.6666666666666666, "full"]` | Super+R cycle; empty uses defaults |
|
||||||
@@ -526,7 +527,7 @@ invalid. A disabled binding must omit `action` and `argument`.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `spawn` | Name in `commands` |
|
| `spawn` | Name in `commands` |
|
||||||
| `binding-mode` | Named mode, or `"default"` to leave it |
|
| `binding-mode` | Named mode, or `"default"` to leave it |
|
||||||
| `focus`, `move`, `resize` | `"left"`, `"right"`, `"up"`, `"down"` |
|
| `focus`, `move`, `resize`, `join-tab` | `"left"`, `"right"`, `"up"`, `"down"` |
|
||||||
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
|
| `workspace`, `move-to-workspace` | Workspace number string, 1–255 |
|
||||||
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor |
|
| `workspace-next`, `workspace-previous` | None; next/previous workspace on this monitor |
|
||||||
| `workspace-back-and-forth` | None; return to the previous workspace on this monitor |
|
| `workspace-back-and-forth` | None; return to the previous workspace on this monitor |
|
||||||
@@ -534,7 +535,7 @@ invalid. A disabled binding must omit `action` and `argument`.
|
|||||||
| `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace |
|
| `focus-floating` | None; focus the last focused window in the opposite tiled/floating layer on this workspace |
|
||||||
| `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order |
|
| `focus-mru-next`, `focus-mru-previous` | None; cycle windows in focus order |
|
||||||
| `none` | None; ignore this trigger |
|
| `none` | None; ignore this trigger |
|
||||||
| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-floating`, `toggle-overview` | None |
|
| `close`, `exit`, `toggle-maximized`, `toggle-fullscreen`, `toggle-layout`, `cycle-column-width`, `center-column`, `consume`, `expel`, `toggle-column-tabbed`, `leave-tab`, `next-tab`, `previous-tab`, `toggle-floating`, `toggle-overview` | None |
|
||||||
|
|
||||||
```kdl
|
```kdl
|
||||||
commands {
|
commands {
|
||||||
@@ -544,6 +545,45 @@ binding keys="Super+Enter" action="spawn" argument="terminal"
|
|||||||
binding keys="Super+V" action="focus-floating"
|
binding keys="Super+V" action="focus-floating"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`join-tab` moves the focused window into the group in the given direction. In
|
||||||
|
tree layout, `consume` chooses a neighbour in left, right, up, down order.
|
||||||
|
`leave-tab` splits the focused tab out to the right of its group. `next-tab` and
|
||||||
|
`previous-tab` cycle within the group and wrap at either end.
|
||||||
|
|
||||||
|
In scrolling layout, **Super+Shift+T** (`toggle-column-tabbed`) switches the
|
||||||
|
focused column between stacked rows and tabs. Toggling back restores its row
|
||||||
|
sizes. `consume` adds a window using the destination column's display mode;
|
||||||
|
`expel` separates it into a column of its own. Up/down focus switches tabs,
|
||||||
|
while left/right focus moves between columns. Tab selection preserves the
|
||||||
|
column's width and scroll position. Vertical resizing is inactive in tabbed mode.
|
||||||
|
`join-tab` explicitly makes the destination column tabbed, including when joining
|
||||||
|
two rows that already share a column.
|
||||||
|
|
||||||
|
The tab bar shows window titles above the active window and reserves space
|
||||||
|
inside its allocation. Click a title to switch. New windows open beside the
|
||||||
|
group. Closing, floating, or moving a tab to another workspace removes it from
|
||||||
|
the group; a single remaining window loses its tab bar. Maximized and fullscreen
|
||||||
|
windows hide the bar. Fullscreen remains per-window: restore before grouping or
|
||||||
|
switching tabs. Switching layout modes keeps every window but dissolves groups.
|
||||||
|
|
||||||
|
To make future scrolling columns tabbed by default:
|
||||||
|
|
||||||
|
```kdl
|
||||||
|
scrolling {
|
||||||
|
default-column-display "tabbed"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The same actions are available through `feresectl`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
feresectl toggle-column-tabbed
|
||||||
|
feresectl join-tab left
|
||||||
|
feresectl next-tab
|
||||||
|
feresectl previous-tab
|
||||||
|
feresectl leave-tab
|
||||||
|
```
|
||||||
|
|
||||||
`focus-floating` switches focus between tiled and floating windows without
|
`focus-floating` switches focus between tiled and floating windows without
|
||||||
changing placement. It stays on the active workspace and does nothing when the
|
changing placement. It stays on the active workspace and does nothing when the
|
||||||
opposite layer has no candidate. `toggle-floating` changes window placement.
|
opposite layer has no candidate. `toggle-floating` changes window placement.
|
||||||
@@ -610,7 +650,8 @@ matches. A mode/configuration transition stops that action's repeats.
|
|||||||
Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus;
|
Defaults: Super+Enter terminal; Super+Q close; Super+H/J/K/L focus;
|
||||||
Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace;
|
Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace;
|
||||||
Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
|
Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center;
|
||||||
Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen;
|
Super+[/] consume/expel; Super+Shift+T stacked/tabbed column; Super+Ctrl+[/] previous/next tab;
|
||||||
|
Super+F maximize; Super+Shift+F fullscreen;
|
||||||
Super+M layout; Super+Shift+Space floating; Super+V focus tiled/floating; Super+Tab overview;
|
Super+M layout; Super+Shift+Space floating; Super+V focus tiled/floating; Super+Tab overview;
|
||||||
Super+Escape previous visited workspace;
|
Super+Escape previous visited workspace;
|
||||||
Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching;
|
Super+BackSpace last focused window; Alt+Tab/Alt+Shift+Tab MRU switching;
|
||||||
|
|||||||
+5
-1
@@ -79,10 +79,14 @@ FERESE_TEST_WINDOW_CAPTURE=1 python3 scripts/tests/test_window_capture_isolated.
|
|||||||
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
|
FERESE_TEST_INHIBIT=1 python3 scripts/tests/test_inhibit_isolated.py
|
||||||
FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py
|
FERESE_TEST_RESTORE=1 python3 scripts/tests/test_restore_isolated.py
|
||||||
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py
|
FERESE_TEST_WINDOW_STREAM=1 python3 scripts/tests/test_window_stream_isolated.py
|
||||||
|
FERESE_TEST_TREE_TABS=1 python3 scripts/tests/test_tree_tabs_isolated.py
|
||||||
```
|
```
|
||||||
|
|
||||||
These checks use disposable nested sessions or private buses to test shortcut
|
These checks use disposable nested sessions or private buses to test shortcut
|
||||||
cleanup, window capture, inhibitors, restored permissions and live window streams.
|
cleanup, window capture, inhibitors, restored permissions, live window streams,
|
||||||
|
and tree and scrolling tab groups. The tab test checks real terminal windows and rendered title
|
||||||
|
pixels; `FERESE_TEST_TABS_SCREENSHOT=/tmp/tabs.png` saves screenshots with `-tree` and
|
||||||
|
`-scrolling` suffixes.
|
||||||
Check each script's dependencies before running it.
|
Check each script's dependencies before running it.
|
||||||
|
|
||||||
For monitor streaming and lock revocation, install Python GI/GStreamer with
|
For monitor streaming and lock revocation, install Python GI/GStreamer with
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ width/height with left/up and grows them with right/down by 32 logical pixels,
|
|||||||
subject to client size limits. Fullscreen and maximized floating windows ignore
|
subject to client size limits. Fullscreen and maximized floating windows ignore
|
||||||
these actions.
|
these actions.
|
||||||
|
|
||||||
|
`toggle-column-tabbed` changes a scrolling column between stacked rows and tabs.
|
||||||
|
In either layout, `join-tab left|right|up|down` joins the focused window to a
|
||||||
|
neighbouring tab group. `next-tab` and `previous-tab` switch tabs, and `leave-tab`
|
||||||
|
splits the focused tab out. See [Commands and bindings](configuration.md#commands-and-bindings)
|
||||||
|
for grouping behavior and shortcuts.
|
||||||
|
|
||||||
## Live Activities
|
## Live Activities
|
||||||
|
|
||||||
Use `feresectl run --title "Tests" -- cargo test` to publish a command's lifetime,
|
Use `feresectl run --title "Tests" -- cargo test` to publish a command's lifetime,
|
||||||
|
|||||||
+12
-3
@@ -49,14 +49,23 @@ two most recently focused windows.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Super + R | Cycle column width |
|
| Super + R | Cycle column width |
|
||||||
| Super + C | Center the focused column |
|
| Super + C | Center the focused column |
|
||||||
| Super + [ | Place the window in a neighbouring column |
|
| Super + [ | Join a neighbouring column or tree tab group |
|
||||||
| Super + ] | Extract the window into its own column |
|
| Super + ] | Leave the column or tree tab group |
|
||||||
|
| Super + Ctrl + [ / ] | Previous / next tab in a group |
|
||||||
|
| Super + Shift + T | Toggle a scrolling column between stacked rows and tabs |
|
||||||
| Super + M | Switch scrolling / tree layout |
|
| Super + M | Switch scrolling / tree layout |
|
||||||
| Super + Tab | Open or close overview |
|
| Super + Tab | Open or close overview |
|
||||||
|
|
||||||
In scrolling mode, **Super + [** uses the left neighbour when available, otherwise
|
In scrolling mode, **Super + [** uses the left neighbour when available, otherwise
|
||||||
the right. Try it with two terminals to place them vertically in one column.
|
the right. Try it with two terminals to place them vertically in one column.
|
||||||
**Super + ]** separates the focused window again.
|
**Super + ]** separates the focused window again. **Super + Shift + T** changes
|
||||||
|
the column to tabs or restores its stacked rows. In tabbed columns, **Super + J/K**
|
||||||
|
switches tabs; the usual left/right focus keys still switch columns.
|
||||||
|
|
||||||
|
In tree mode, **Super + [** joins the focused window to a neighbouring window
|
||||||
|
as a tab. Click a title in the tab bar or use **Super + Ctrl + [ / ]** to switch
|
||||||
|
tabs. **Super + ]** splits the focused tab out beside the group. For a specific
|
||||||
|
neighbour, bind `join-tab` with a direction; see [Commands and bindings](configuration.md#commands-and-bindings).
|
||||||
|
|
||||||
In overview, select a workspace to see its windows, then click the window you
|
In overview, select a workspace to see its windows, then click the window you
|
||||||
want. Directional focus keys move selection; Enter activates it and Escape closes
|
want. Directional focus keys move selection; Enter activates it and Escape closes
|
||||||
|
|||||||
+17
-2
@@ -70,9 +70,19 @@ pub enum Command {
|
|||||||
CycleColumnWidth,
|
CycleColumnWidth,
|
||||||
/// Center the focused column.
|
/// Center the focused column.
|
||||||
CenterColumn,
|
CenterColumn,
|
||||||
/// Pull a window into the focused column.
|
/// Toggle the focused scrolling column between stacked windows and tabs.
|
||||||
|
ToggleColumnTabbed,
|
||||||
|
/// Join the focused window to the adjacent tab group.
|
||||||
|
JoinTab { direction: Direction },
|
||||||
|
/// Split the focused tab out beside its group.
|
||||||
|
LeaveTab,
|
||||||
|
/// Focus the next tab in the group.
|
||||||
|
NextTab,
|
||||||
|
/// Focus the previous tab in the group.
|
||||||
|
PreviousTab,
|
||||||
|
/// Join a neighboring column or tree tab group.
|
||||||
Consume,
|
Consume,
|
||||||
/// Move a window out of the focused column.
|
/// Leave the focused column or tree tab group.
|
||||||
Expel,
|
Expel,
|
||||||
/// Close the focused window.
|
/// Close the focused window.
|
||||||
Close,
|
Close,
|
||||||
@@ -244,6 +254,11 @@ impl Command {
|
|||||||
Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})),
|
Self::ToggleKeybindingGuide => ("toggle-keybinding-guide", json!({})),
|
||||||
Self::CycleColumnWidth => ("cycle-column-width", json!({})),
|
Self::CycleColumnWidth => ("cycle-column-width", json!({})),
|
||||||
Self::CenterColumn => ("center-column", json!({})),
|
Self::CenterColumn => ("center-column", json!({})),
|
||||||
|
Self::ToggleColumnTabbed => ("toggle-column-tabbed", json!({})),
|
||||||
|
Self::JoinTab { direction } => ("join-tab", json!({"direction": value_name(direction)})),
|
||||||
|
Self::LeaveTab => ("leave-tab", json!({})),
|
||||||
|
Self::NextTab => ("next-tab", json!({})),
|
||||||
|
Self::PreviousTab => ("previous-tab", json!({})),
|
||||||
Self::Consume => ("consume", json!({})),
|
Self::Consume => ("consume", json!({})),
|
||||||
Self::Expel => ("expel", json!({})),
|
Self::Expel => ("expel", json!({})),
|
||||||
Self::Close => ("close", json!({})),
|
Self::Close => ("close", json!({})),
|
||||||
|
|||||||
@@ -365,6 +365,22 @@ mod tests {
|
|||||||
assert!(parse_args(["output-profile".into()]).is_err());
|
assert!(parse_args(["output-profile".into()]).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tab_commands_validate_directions_and_reject_extra_arguments() {
|
||||||
|
for direction in ["left", "right", "up", "down"] {
|
||||||
|
assert_eq!(
|
||||||
|
parse_args(["join-tab".into(), direction.into()]).unwrap(),
|
||||||
|
("join-tab".into(), json!({"direction": direction}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert!(parse_args(["join-tab".into()]).is_err());
|
||||||
|
assert!(parse_args(["join-tab".into(), "diagonal".into()]).is_err());
|
||||||
|
for action in ["toggle-column-tabbed", "leave-tab", "next-tab", "previous-tab"] {
|
||||||
|
assert_eq!(parse_args([action.into()]).unwrap(), (action.into(), json!({})));
|
||||||
|
assert!(parse_args([action.into(), "extra".into()]).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn rejects_unknown_or_missing_arguments() {
|
fn rejects_unknown_or_missing_arguments() {
|
||||||
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));
|
assert_eq!(parse_args(["exit".to_owned()]).unwrap(), ("exit".to_owned(), json!({})));
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Exercise tree and scrolling tabs with real clients on a private nested desktop.
|
||||||
|
|
||||||
|
FERESE_TEST_TREE_TABS=1 python3 scripts/tests/test_tree_tabs_isolated.py
|
||||||
|
Requires foot, dbus-daemon, Pillow, and a Wayland host. FERESE_TEST_BINARY and
|
||||||
|
FERESE_TEST_CTL may point to build artifacts outside target/debug.
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
from session_socket import ipc_environment, ipc_socket
|
||||||
|
import signal
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(os.environ.get("FERESE_TEST_TREE_TABS") == "1", "nested tab test is opt-in")
|
||||||
|
class TabGroupsTest(unittest.TestCase):
|
||||||
|
def test_tree_join_cycle_leave_and_close(self):
|
||||||
|
self.exercise("tree")
|
||||||
|
|
||||||
|
def test_scrolling_join_cycle_leave_and_close(self):
|
||||||
|
self.exercise("scrolling")
|
||||||
|
|
||||||
|
def exercise(self, mode):
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
repo = Path(__file__).resolve().parents[2]
|
||||||
|
binary = repo / os.environ.get("FERESE_TEST_BINARY", "target/debug/ferese")
|
||||||
|
ctl = repo / os.environ.get("FERESE_TEST_CTL", "target/debug/feresectl")
|
||||||
|
children = []
|
||||||
|
with tempfile.TemporaryDirectory(prefix="ferese-tabs-") as temporary:
|
||||||
|
root = Path(temporary)
|
||||||
|
runtime = root / "runtime"
|
||||||
|
runtime.mkdir(mode=0o700)
|
||||||
|
config = root / "config/ferese"
|
||||||
|
config.mkdir(parents=True)
|
||||||
|
(config / "config.kdl").write_text(f'layout {{ mode "{mode}"; }}\nanimations {{ enabled #false; }}\n')
|
||||||
|
host = Path(os.environ["WAYLAND_DISPLAY"])
|
||||||
|
if not host.is_absolute():
|
||||||
|
host = Path(os.environ["XDG_RUNTIME_DIR"]) / host
|
||||||
|
env = dict(os.environ, WAYLAND_DISPLAY=str(host), XDG_RUNTIME_DIR=str(runtime),
|
||||||
|
XDG_CONFIG_HOME=str(root / "config"), XDG_STATE_HOME=str(root / "state"),
|
||||||
|
XDG_DATA_HOME=str(root / "data"))
|
||||||
|
env.pop("WAYLAND_SOCKET", None)
|
||||||
|
env.pop("FERESE_SHELL_CONTROL_SOCKET", None)
|
||||||
|
log_path = root / "compositor.log"
|
||||||
|
|
||||||
|
def launch(command, **kwargs):
|
||||||
|
child = subprocess.Popen(command, env=ipc_environment(env), start_new_session=True, **kwargs)
|
||||||
|
children.append(child)
|
||||||
|
return child
|
||||||
|
|
||||||
|
def command(*args):
|
||||||
|
return json.loads(subprocess.check_output([str(ctl), "-j", *args],
|
||||||
|
env=ipc_environment(env), timeout=10))
|
||||||
|
|
||||||
|
def wait_for(predicate):
|
||||||
|
deadline = time.monotonic() + 15
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
result = predicate()
|
||||||
|
if result:
|
||||||
|
return result
|
||||||
|
time.sleep(.025)
|
||||||
|
self.fail("Timed out:\n" + log_path.read_text())
|
||||||
|
|
||||||
|
def windows():
|
||||||
|
return command("windows")
|
||||||
|
|
||||||
|
def mapped():
|
||||||
|
return [w for w in windows() if w["mapped"]]
|
||||||
|
|
||||||
|
with log_path.open("w") as log:
|
||||||
|
try:
|
||||||
|
bus = launch(["dbus-daemon", "--session", "--nofork", "--print-address=1"],
|
||||||
|
stdout=subprocess.PIPE, stderr=log, text=True)
|
||||||
|
env["DBUS_SESSION_BUS_ADDRESS"] = bus.stdout.readline().strip()
|
||||||
|
launch([str(binary), "--backend", "nested"], stdout=log, stderr=log)
|
||||||
|
wait_for(lambda: ipc_socket(runtime).is_socket())
|
||||||
|
env["WAYLAND_DISPLAY"] = str(next(p for p in runtime.glob("wayland-*") if p.is_socket()))
|
||||||
|
for index, title in enumerate(("Editor", "Build", "Logs"), 1):
|
||||||
|
launch(["foot", "--app-id", f"ferese.test.tabs.{index}", "--title", title,
|
||||||
|
"sh", "-c", f"printf '{title}\\n'; sleep 120"], stdout=log, stderr=log)
|
||||||
|
wait_for(lambda: len(windows()) == index and len(mapped()) >= 1)
|
||||||
|
if index == 1 and mode == "scrolling":
|
||||||
|
command("toggle-column-tabbed")
|
||||||
|
if index > 1:
|
||||||
|
command("consume")
|
||||||
|
wait_for(lambda: len(mapped()) == 1)
|
||||||
|
ids = [w["id"] for w in windows()]
|
||||||
|
self.assertEqual(command("focused-window")["id"], ids[-1])
|
||||||
|
for expected in ids:
|
||||||
|
command("next-tab")
|
||||||
|
wait_for(lambda: len(mapped()) == 1 and mapped()[0]["id"] == expected)
|
||||||
|
command("previous-tab")
|
||||||
|
self.assertEqual(command("focused-window")["id"], ids[1])
|
||||||
|
# New windows split beside the group; they must not become hidden tabs.
|
||||||
|
launch(["foot", "--title", "Neighbor", "sleep", "120"], stdout=log, stderr=log)
|
||||||
|
wait_for(lambda: len(windows()) == 4 and len(mapped()) == 2)
|
||||||
|
neighbor = command("focused-window")["id"]
|
||||||
|
neighbor_rect = next(w for w in mapped() if w["id"] == neighbor)
|
||||||
|
command("focus-last-window")
|
||||||
|
command("next-tab")
|
||||||
|
wait_for(lambda: len(mapped()) == 2)
|
||||||
|
current_neighbor = next(w for w in mapped() if w["id"] == neighbor)
|
||||||
|
for key in ("x", "y", "width", "height"):
|
||||||
|
self.assertEqual(current_neighbor[key], neighbor_rect[key])
|
||||||
|
# Inspect actual text pixels; mapped windows alone do not prove the bar rendered.
|
||||||
|
active = next(w for w in mapped() if w["focused"])
|
||||||
|
scale = command("outputs")[0]["scale"]
|
||||||
|
captured = None
|
||||||
|
|
||||||
|
def labels_rendered():
|
||||||
|
nonlocal captured
|
||||||
|
captured = subprocess.check_output([str(ctl), "screenshot"],
|
||||||
|
env=ipc_environment(env), stderr=log, timeout=10)
|
||||||
|
image = Image.open(io.BytesIO(captured)).convert("RGB")
|
||||||
|
for index in range(3):
|
||||||
|
left = active["x"] + active["width"] * index / 3 + 4
|
||||||
|
right = left + min(100, active["width"] / 3 - 8)
|
||||||
|
box = (round(left * scale), round((active["y"] - 25) * scale),
|
||||||
|
round(right * scale), round((active["y"] - 5) * scale))
|
||||||
|
pixels = image.crop(box).tobytes()
|
||||||
|
if not any(min(pixels[i:i + 3]) > 130 for i in range(0, len(pixels), 3)):
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
wait_for(labels_rendered)
|
||||||
|
if screenshot := os.environ.get("FERESE_TEST_TABS_SCREENSHOT"):
|
||||||
|
path = Path(screenshot)
|
||||||
|
path.with_stem(path.stem + "-" + mode).write_bytes(captured)
|
||||||
|
command("leave-tab")
|
||||||
|
wait_for(lambda: len(mapped()) == 3)
|
||||||
|
command("close")
|
||||||
|
wait_for(lambda: len(windows()) == 3 and len(mapped()) == 2)
|
||||||
|
command("toggle-layout")
|
||||||
|
wait_for(lambda: len(mapped()) == 3)
|
||||||
|
finally:
|
||||||
|
for child in reversed(children):
|
||||||
|
if child.poll() is None:
|
||||||
|
os.killpg(child.pid, signal.SIGTERM)
|
||||||
|
for child in reversed(children):
|
||||||
|
try:
|
||||||
|
child.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
os.killpg(child.pid, signal.SIGKILL)
|
||||||
|
child.wait(timeout=5)
|
||||||
|
if bus.stdout:
|
||||||
|
bus.stdout.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user