feat(layout): add tabbed window groups and scrolling columns
This commit is contained in:
1 parent
7d58eeb65d
commit
bfa78f62f2
27 files changed
+1480
-47
No files matched your search
@@ -166,6 +166,7 @@ impl Config {
|
||||
.and_then(|panel| panel.background_opacity),
|
||||
focus_effect: self.focus_effect_settings()?,
|
||||
default_column_width: self.default_column_width()?,
|
||||
default_column_display: self.default_column_display(),
|
||||
scrolling_focus_strategy: self.scrolling_focus_strategy(),
|
||||
column_width_presets: self.width_presets()?,
|
||||
animations_enabled: self.animations_enabled(),
|
||||
@@ -229,12 +230,20 @@ enum LayoutModeValue {
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct ScrollingConfig {
|
||||
default_column_display: Option<ColumnDisplayValue>,
|
||||
default_column_width: Option<ColumnWidthValue>,
|
||||
focus_strategy: Option<FocusStrategyValue>,
|
||||
#[serde(default)]
|
||||
width_presets: Vec<ColumnWidthValue>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum ColumnDisplayValue {
|
||||
Stacked,
|
||||
Tabbed,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
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]
|
||||
fn supplies_complete_v0_bindings_and_terminal_command() {
|
||||
let config = parse("");
|
||||
let input = config.input_settings().unwrap();
|
||||
let bindings = config.bindings(&input).unwrap();
|
||||
|
||||
assert_eq!(bindings.len(), 59);
|
||||
assert_eq!(bindings.len(), 62);
|
||||
for (shift, action) in [
|
||||
(false, BindingAction::ToggleMaximized),
|
||||
(true, BindingAction::ToggleFullscreen),
|
||||
@@ -1176,6 +1217,10 @@ mod tests {
|
||||
&& binding.action == action));
|
||||
}
|
||||
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| {
|
||||
binding.modifiers.logo
|
||||
&& binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return)
|
||||
@@ -1209,7 +1254,7 @@ mod tests {
|
||||
|
||||
let input = replaced.input_settings().unwrap();
|
||||
let bindings = replaced.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 59);
|
||||
assert_eq!(bindings.len(), 62);
|
||||
assert!(bindings.iter().any(|binding| {
|
||||
binding.action
|
||||
== 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 bindings = unbound.bindings(&input).unwrap();
|
||||
assert_eq!(bindings.len(), 58);
|
||||
assert_eq!(bindings.len(), 61);
|
||||
assert!(!bindings.iter().any(|binding| binding.action == BindingAction::Close));
|
||||
}
|
||||
|
||||
|
||||
@@ -333,6 +333,23 @@ pub(super) fn parse_action(
|
||||
no_argument()?;
|
||||
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" => {
|
||||
no_argument()?;
|
||||
Ok(BindingAction::Consume)
|
||||
@@ -403,6 +420,9 @@ pub(super) fn default_bindings() -> Vec<BindingConfig> {
|
||||
binding("Super+C", "center-column", None),
|
||||
binding("Super+[", "consume", 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+V", "focus-floating", None),
|
||||
binding("Super+Tab", "toggle-overview", None),
|
||||
@@ -495,6 +515,10 @@ pub enum BindingAction {
|
||||
ToggleLayout,
|
||||
CycleColumnWidth,
|
||||
CenterColumn,
|
||||
ToggleColumnTabbed,
|
||||
JoinTab(ferese_layout::Direction),
|
||||
LeaveTab,
|
||||
CycleTab(bool),
|
||||
Consume,
|
||||
Expel,
|
||||
ToggleFloating,
|
||||
@@ -609,8 +633,12 @@ impl Binding {
|
||||
BindingAction::ToggleLayout => "Switch workspace layout".into(),
|
||||
BindingAction::CycleColumnWidth => "Cycle column width".into(),
|
||||
BindingAction::CenterColumn => "Center focused column".into(),
|
||||
BindingAction::Consume => "Join window into column".into(),
|
||||
BindingAction::Expel => "Move window out of column".into(),
|
||||
BindingAction::ToggleColumnTabbed => "Toggle stacked / tabbed 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::Media(action) => match *action {
|
||||
"next" => "Next track",
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
use super::*;
|
||||
|
||||
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 {
|
||||
match self.layout.mode.unwrap_or(LayoutModeValue::Scrolling) {
|
||||
LayoutModeValue::Scrolling => LayoutMode::Scrolling,
|
||||
|
||||
@@ -550,6 +550,9 @@ impl Ferese {
|
||||
}
|
||||
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 serial = SERIAL_COUNTER.next_serial();
|
||||
if self.session_lock.active() {
|
||||
@@ -580,6 +583,17 @@ impl Ferese {
|
||||
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
|
||||
// effects-capable shell popups, release input now but defer
|
||||
// popup_done until the compositor's whole-surface fade ends.
|
||||
@@ -907,6 +921,9 @@ impl Ferese {
|
||||
self.focus_lock_surface();
|
||||
return;
|
||||
}
|
||||
if self.tab_at(position).is_some() {
|
||||
return;
|
||||
}
|
||||
let keyboard = self.seat.get_keyboard().expect("seat has a keyboard");
|
||||
self.focus_output_at(position);
|
||||
|
||||
@@ -1161,6 +1178,10 @@ impl Ferese {
|
||||
BindingAction::ToggleLayout => self.toggle_layout_mode(),
|
||||
BindingAction::CycleColumnWidth => self.cycle_focused_column_width(),
|
||||
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::Expel => self.expel_focused_window(),
|
||||
BindingAction::ToggleFloating => self.toggle_focused_floating(),
|
||||
|
||||
@@ -1084,6 +1084,11 @@ impl Ferese {
|
||||
}
|
||||
"cycle-column-width" => self.cycle_focused_column_width(),
|
||||
"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(),
|
||||
"expel" => self.expel_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| {
|
||||
if let channel::Event::Msg((family, font)) = event
|
||||
&& 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);
|
||||
}
|
||||
@@ -60,20 +60,7 @@ pub(crate) fn init_font_loader(
|
||||
// Rescan on a font change so newly installed fonts remain usable.
|
||||
let mut database = fontdb::Database::new();
|
||||
database.load_system_fonts();
|
||||
let families = [fontdb::Family::Name(&family), fontdb::Family::SansSerif];
|
||||
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();
|
||||
let font = label_font(&database, &family);
|
||||
if results.send((family, font)).is_err() {
|
||||
break;
|
||||
}
|
||||
@@ -85,6 +72,32 @@ pub(crate) fn init_font_loader(
|
||||
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 LabelStyle = (u64, [u32; 4]);
|
||||
type OverviewStateLabel = HashMap<String, HashMap<LabelStyle, LabelBuffer>>;
|
||||
@@ -1177,6 +1190,14 @@ mod tests {
|
||||
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]
|
||||
fn font_reload_keeps_labels_until_current_result_arrives() {
|
||||
let mut overview = OverviewState::with_font_family("first".into());
|
||||
|
||||
@@ -11,6 +11,7 @@ mod overview;
|
||||
mod resources;
|
||||
mod scene;
|
||||
mod shader;
|
||||
mod tabs;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
@@ -329,6 +330,8 @@ pub(crate) enum OverviewChromePart {
|
||||
Outline,
|
||||
Strip,
|
||||
Caption,
|
||||
Tab,
|
||||
TabActive,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
|
||||
@@ -122,7 +122,9 @@ pub(super) fn overview_strip_elements(
|
||||
&& let Some(cache) = state.render.outputs.get_mut(&output_id)
|
||||
{
|
||||
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::Strip => true,
|
||||
});
|
||||
|
||||
@@ -69,6 +69,11 @@ impl RenderResources {
|
||||
|
||||
pub fn remove_window(&mut self, id: WindowId) {
|
||||
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) {
|
||||
|
||||
@@ -195,6 +195,7 @@ fn scene_elements(
|
||||
if let Some(group) = closing.remove(&Some(id)) {
|
||||
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 material_surface = window
|
||||
.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 outputs;
|
||||
mod reconciliation;
|
||||
mod tabs;
|
||||
#[cfg(test)]
|
||||
mod window_lifecycle_tests;
|
||||
pub(crate) use reconciliation::{DesktopChanges, DesktopOutput, DesktopTransition};
|
||||
@@ -455,6 +456,8 @@ pub struct Ferese {
|
||||
workspace_slides: HashMap<OutputId, WorkspaceSlide>,
|
||||
workspace_slide_offsets: HashMap<WorkspaceId, (f64, f64)>,
|
||||
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_cycle: Option<ferese_core::FocusCycle>,
|
||||
column_width_presets: Vec<ColumnWidth>,
|
||||
@@ -572,6 +575,7 @@ pub struct RuntimeConfig {
|
||||
pub(crate) panel_style: ferese_panel::PanelStyle,
|
||||
pub focus_effect: crate::config::FocusEffectSettings,
|
||||
pub default_column_width: ColumnWidth,
|
||||
pub default_column_display: ferese_layout::ColumnDisplay,
|
||||
pub scrolling_focus_strategy: ViewportFocusStrategy,
|
||||
pub column_width_presets: Vec<ColumnWidth>,
|
||||
pub animations_enabled: bool,
|
||||
@@ -748,6 +752,8 @@ impl Ferese {
|
||||
workspace_slides: HashMap::new(),
|
||||
workspace_slide_offsets: HashMap::new(),
|
||||
focused_window: None,
|
||||
tab_bars: HashMap::new(),
|
||||
tab_buttons: HashSet::new(),
|
||||
focus_history: Default::default(),
|
||||
focus_cycle: None,
|
||||
column_width_presets: config.column_width_presets,
|
||||
@@ -846,6 +852,9 @@ impl Ferese {
|
||||
state.screenshot_worker = Some(screenshot.worker);
|
||||
state._ipc_socket = Some(screenshot._guard);
|
||||
|
||||
state
|
||||
.workspaces
|
||||
.set_default_column_display(config.default_column_display);
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
@@ -951,6 +960,8 @@ impl Ferese {
|
||||
// Hidden layouts are resolved independently of presented geometry.
|
||||
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.input_settings = config.input_settings;
|
||||
if bindings_changed || input_changed {
|
||||
|
||||
@@ -13,6 +13,9 @@ impl Ferese {
|
||||
if self.overview_strip_at(position).is_some() {
|
||||
return None;
|
||||
}
|
||||
if self.tab_at(position).is_some() {
|
||||
return None;
|
||||
}
|
||||
self.layer_surface_under(position, &[Layer::Overlay, Layer::Top])
|
||||
.map(|(_, surface, origin)| (surface, origin))
|
||||
.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 {
|
||||
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.
|
||||
} else {
|
||||
self.gap_config.outer
|
||||
@@ -118,6 +122,12 @@ impl Ferese {
|
||||
|
||||
pub(super) fn unmap_invisible_windows(&mut self, visible: &HashSet<WindowId>) -> bool {
|
||||
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
|
||||
.windows
|
||||
.ids()
|
||||
@@ -136,6 +146,11 @@ impl Ferese {
|
||||
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.refresh_idle_inhibition();
|
||||
@@ -195,6 +210,7 @@ impl Ferese {
|
||||
self.refresh_input_capture_zones();
|
||||
let visible_workspaces = self.visible_workspace_ids();
|
||||
self.reconcile_workspaces(&visible_workspaces);
|
||||
self.tab_bars.retain(|id, _| visible_workspaces.contains(id));
|
||||
if self.session_lock.active() {
|
||||
self.configure_lock_surfaces();
|
||||
}
|
||||
@@ -228,11 +244,18 @@ impl Ferese {
|
||||
.iter()
|
||||
.find(|column| column.windows.contains(&id))
|
||||
.and_then(|column| {
|
||||
column.windows.iter().find_map(|sibling| {
|
||||
self.windows.transaction(sibling).and_then(|transaction| {
|
||||
transaction.column_width().map(|(source, _)| source)
|
||||
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| {
|
||||
transaction.column_width().map(|(source, _)| source)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
.or_else(|| layout.allocated_column_width(id))?,
|
||||
@@ -255,9 +278,12 @@ impl Ferese {
|
||||
// to every row; sibling configure holds already release the
|
||||
// whole column together, including partial client commits.
|
||||
let mut rows = column.windows.iter().filter(|id| {
|
||||
self.windows
|
||||
.geometry(id)
|
||||
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
||||
(column.display != ferese_layout::ColumnDisplay::Tabbed
|
||||
|| **id == column.windows[column.active])
|
||||
&& self
|
||||
.windows
|
||||
.geometry(id)
|
||||
.is_some_and(|geometry| !geometry.is_zooming() && !geometry.is_fullscreen())
|
||||
});
|
||||
let source = rows
|
||||
.clone()
|
||||
@@ -345,6 +371,7 @@ impl Ferese {
|
||||
continue;
|
||||
}
|
||||
};
|
||||
self.tab_bars.insert(workspace_id, layout.tab_bars);
|
||||
let viewport_target = workspace.layout.viewport_x();
|
||||
let viewport_motion = viewport_target.map(|target| {
|
||||
let viewport = self
|
||||
@@ -380,6 +407,10 @@ impl Ferese {
|
||||
self.windows.record(*id).is_some_and(|w| w.maximized) && workspace_fullscreen != Some(*id);
|
||||
let placement = self.workspaces.placement(*id);
|
||||
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) {
|
||||
fullscreen_bounds
|
||||
} else if is_maximized && (!is_scrolling_layout || is_floating) {
|
||||
|
||||
@@ -270,13 +270,30 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn consume_focused_window(&mut self) {
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(window) = self.focused_window else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = self.output_bounds() else {
|
||||
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;
|
||||
}
|
||||
let target = self
|
||||
@@ -306,6 +323,9 @@ impl Ferese {
|
||||
}
|
||||
|
||||
pub fn expel_focused_window(&mut self) {
|
||||
if self.workspaces.active().fullscreen.is_some() {
|
||||
return;
|
||||
}
|
||||
let Some(window) = self.focused_window else {
|
||||
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);
|
||||
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]);
|
||||
}
|
||||
Reference in new issue
Block a user