taskers-domain 0.7.0

Domain model for taskers workspaces, panes, and layout state.
Documentation
use std::cmp::Ordering;

use serde::{Deserialize, Serialize};

use crate::{PaneContainerId, PaneId};

const MIN_SPLIT_RATIO: u16 = 150;
const MAX_SPLIT_RATIO: u16 = 850;
const ROOT_LAYOUT_SIZE: f32 = 1000.0;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitAxis {
    Horizontal,
    Vertical,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Direction {
    Left,
    Right,
    Up,
    Down,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum SplitLayoutNode<LeafId> {
    Leaf {
        leaf_id: LeafId,
    },
    Split {
        axis: SplitAxis,
        ratio: u16,
        first: Box<SplitLayoutNode<LeafId>>,
        second: Box<SplitLayoutNode<LeafId>>,
    },
}

pub type LayoutNode = SplitLayoutNode<PaneContainerId>;
pub type PaneTabLayoutNode = SplitLayoutNode<PaneId>;

impl<LeafId> SplitLayoutNode<LeafId>
where
    LeafId: Copy + Eq,
{
    pub fn is_leaf(&self) -> bool {
        matches!(self, Self::Leaf { .. })
    }

    pub fn leaf(leaf_id: LeafId) -> Self {
        Self::Leaf { leaf_id }
    }

    pub fn split_leaf(
        &mut self,
        target: LeafId,
        axis: SplitAxis,
        new_leaf: LeafId,
        ratio: u16,
    ) -> bool {
        let direction = match axis {
            SplitAxis::Horizontal => Direction::Right,
            SplitAxis::Vertical => Direction::Down,
        };
        self.split_leaf_with_direction(target, direction, new_leaf, ratio)
    }

    pub fn split_leaf_with_direction(
        &mut self,
        target: LeafId,
        direction: Direction,
        new_leaf: LeafId,
        ratio: u16,
    ) -> bool {
        let (axis, new_leaf_first) = match direction {
            Direction::Left => (SplitAxis::Horizontal, true),
            Direction::Right => (SplitAxis::Horizontal, false),
            Direction::Up => (SplitAxis::Vertical, true),
            Direction::Down => (SplitAxis::Vertical, false),
        };
        match self {
            Self::Leaf { leaf_id } if *leaf_id == target => {
                let existing = *leaf_id;
                let (first, second) = if new_leaf_first {
                    (Self::leaf(new_leaf), Self::leaf(existing))
                } else {
                    (Self::leaf(existing), Self::leaf(new_leaf))
                };
                *self = Self::Split {
                    axis,
                    ratio: clamp_ratio(ratio),
                    first: Box::new(first),
                    second: Box::new(second),
                };
                true
            }
            Self::Leaf { .. } => false,
            Self::Split { first, second, .. } => {
                first.split_leaf_with_direction(target, direction, new_leaf, ratio)
                    || second.split_leaf_with_direction(target, direction, new_leaf, ratio)
            }
        }
    }

    pub fn remove_leaf(&mut self, target: LeafId) -> bool {
        match self {
            Self::Leaf { leaf_id } if *leaf_id == target => false,
            Self::Leaf { .. } => false,
            Self::Split { first, second, .. } => {
                if let Self::Leaf { leaf_id } = first.as_ref()
                    && *leaf_id == target
                {
                    *self = *second.clone();
                    return true;
                }
                if let Self::Leaf { leaf_id } = second.as_ref()
                    && *leaf_id == target
                {
                    *self = *first.clone();
                    return true;
                }
                first.remove_leaf(target) || second.remove_leaf(target)
            }
        }
    }

    pub fn contains(&self, target: LeafId) -> bool {
        match self {
            Self::Leaf { leaf_id } => *leaf_id == target,
            Self::Split { first, second, .. } => first.contains(target) || second.contains(target),
        }
    }

    pub fn leaves(&self) -> Vec<LeafId> {
        match self {
            Self::Leaf { leaf_id } => vec![*leaf_id],
            Self::Split { first, second, .. } => {
                let mut leaves = first.leaves();
                leaves.extend(second.leaves());
                leaves
            }
        }
    }

    pub fn focus_neighbor(&self, target: LeafId, direction: Direction) -> Option<LeafId> {
        let leaves = self.collect_leaf_rects();
        let (_, target_rect) = leaves
            .iter()
            .find(|(leaf_id, _)| *leaf_id == target)
            .copied()?;

        leaves
            .into_iter()
            .filter(|(leaf_id, _)| *leaf_id != target)
            .filter_map(|(leaf_id, rect)| {
                rect.directional_score(target_rect, direction)
                    .map(|score| (leaf_id, score))
            })
            .min_by(|(_, left), (_, right)| left.partial_cmp(right).unwrap_or(Ordering::Equal))
            .map(|(leaf_id, _)| leaf_id)
    }

    pub fn resize_leaf(&mut self, target: LeafId, direction: Direction, amount: i32) -> bool {
        self.resize_leaf_inner(target, direction, amount.unsigned_abs() as u16)
            .is_some()
    }

    pub fn set_ratio_at_path(&mut self, path: &[bool], ratio: u16) -> bool {
        let ratio = clamp_ratio(ratio);
        if path.is_empty() {
            if let Self::Split {
                ratio: current_ratio,
                ..
            } = self
            {
                *current_ratio = ratio;
                return true;
            }
            return false;
        }

        match self {
            Self::Split { first, second, .. } => {
                let (head, tail) = path.split_first().expect("path is not empty");
                if *head {
                    second.set_ratio_at_path(tail, ratio)
                } else {
                    first.set_ratio_at_path(tail, ratio)
                }
            }
            Self::Leaf { .. } => false,
        }
    }

    fn resize_leaf_inner(
        &mut self,
        target: LeafId,
        direction: Direction,
        amount: u16,
    ) -> Option<bool> {
        match self {
            Self::Leaf { leaf_id } => (*leaf_id == target).then_some(false),
            Self::Split {
                axis,
                ratio,
                first,
                second,
            } => {
                let found_in_first = first.contains(target);
                let child_result = if found_in_first {
                    first.resize_leaf_inner(target, direction, amount)
                } else {
                    second.resize_leaf_inner(target, direction, amount)
                };

                match child_result {
                    Some(true) => Some(true),
                    Some(false) => {
                        let delta = split_resize_delta(*axis, direction, found_in_first, amount)?;
                        *ratio = apply_ratio_delta(*ratio, delta);
                        Some(true)
                    }
                    None => None,
                }
            }
        }
    }

    fn collect_leaf_rects(&self) -> Vec<(LeafId, LayoutRect)> {
        let mut leaves = Vec::new();
        self.collect_leaf_rects_into(
            LayoutRect {
                x: 0.0,
                y: 0.0,
                width: ROOT_LAYOUT_SIZE,
                height: ROOT_LAYOUT_SIZE,
            },
            &mut leaves,
        );
        leaves
    }

    fn collect_leaf_rects_into(&self, rect: LayoutRect, out: &mut Vec<(LeafId, LayoutRect)>) {
        match self {
            Self::Leaf { leaf_id } => out.push((*leaf_id, rect)),
            Self::Split {
                axis,
                ratio,
                first,
                second,
            } => {
                let ratio = f32::from(*ratio) / 1000.0;
                match axis {
                    SplitAxis::Horizontal => {
                        let first_width = rect.width * ratio;
                        first.collect_leaf_rects_into(
                            LayoutRect {
                                width: first_width,
                                ..rect
                            },
                            out,
                        );
                        second.collect_leaf_rects_into(
                            LayoutRect {
                                x: rect.x + first_width,
                                width: rect.width - first_width,
                                ..rect
                            },
                            out,
                        );
                    }
                    SplitAxis::Vertical => {
                        let first_height = rect.height * ratio;
                        first.collect_leaf_rects_into(
                            LayoutRect {
                                height: first_height,
                                ..rect
                            },
                            out,
                        );
                        second.collect_leaf_rects_into(
                            LayoutRect {
                                y: rect.y + first_height,
                                height: rect.height - first_height,
                                ..rect
                            },
                            out,
                        );
                    }
                }
            }
        }
    }
}

#[derive(Debug, Clone, Copy)]
struct LayoutRect {
    x: f32,
    y: f32,
    width: f32,
    height: f32,
}

impl LayoutRect {
    fn center_x(self) -> f32 {
        self.x + (self.width / 2.0)
    }

    fn center_y(self) -> f32 {
        self.y + (self.height / 2.0)
    }

    fn directional_score(self, target: Self, direction: Direction) -> Option<f32> {
        let primary = match direction {
            Direction::Left => target.center_x() - self.center_x(),
            Direction::Right => self.center_x() - target.center_x(),
            Direction::Up => target.center_y() - self.center_y(),
            Direction::Down => self.center_y() - target.center_y(),
        };
        if primary <= 0.0 {
            return None;
        }

        let secondary = match direction {
            Direction::Left | Direction::Right => (self.center_y() - target.center_y()).abs(),
            Direction::Up | Direction::Down => (self.center_x() - target.center_x()).abs(),
        };

        Some((primary * 10.0) + secondary)
    }
}

fn clamp_ratio(ratio: u16) -> u16 {
    ratio.clamp(MIN_SPLIT_RATIO, MAX_SPLIT_RATIO)
}

fn split_resize_delta(
    axis: SplitAxis,
    direction: Direction,
    found_in_first: bool,
    amount: u16,
) -> Option<i32> {
    match axis {
        SplitAxis::Horizontal => match (found_in_first, direction) {
            (true, Direction::Right) => Some(i32::from(amount)),
            (true, Direction::Left) => Some(-i32::from(amount)),
            (false, Direction::Right) => Some(-i32::from(amount)),
            (false, Direction::Left) => Some(i32::from(amount)),
            _ => None,
        },
        SplitAxis::Vertical => match (found_in_first, direction) {
            (true, Direction::Down) => Some(i32::from(amount)),
            (true, Direction::Up) => Some(-i32::from(amount)),
            (false, Direction::Down) => Some(-i32::from(amount)),
            (false, Direction::Up) => Some(i32::from(amount)),
            _ => None,
        },
    }
}

fn apply_ratio_delta(current: u16, delta: i32) -> u16 {
    (i32::from(current) + delta).clamp(i32::from(MIN_SPLIT_RATIO), i32::from(MAX_SPLIT_RATIO))
        as u16
}