use crate::event::Rect;
use crate::primitives::chart::Chart;
use crate::primitives::form::Form;
use crate::primitives::list::ListView;
use crate::primitives::message_list::MessageList;
use crate::primitives::terminal::Terminal;
use crate::primitives::tree::TreeView;
use crate::types::{Icon, StyledText, WidgetId};
use serde::{Deserialize, Serialize};
pub type SectionId = String;
pub type ActionId = String;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Axis {
#[default]
Vertical,
Horizontal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum ScrollMode {
#[default]
PerSection,
WholePanel,
}
#[derive(Debug, Clone, Copy, PartialEq, Default, Serialize, Deserialize)]
pub enum SectionSize {
Fixed(u16),
Percent(f32),
Weight(f32),
Content,
#[default]
EqualShare,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeaderAction {
pub id: ActionId,
pub icon: Icon,
pub tooltip: Option<String>,
#[serde(default = "default_true")]
pub enabled: bool,
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct SectionHeader {
pub icon: Option<Icon>,
pub title: StyledText,
pub badge: Option<StyledText>,
#[serde(default)]
pub actions: Vec<HeaderAction>,
#[serde(default = "default_true")]
pub show_chevron: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct EmptyBody {
pub icon: Option<Icon>,
pub text: StyledText,
pub hint: Option<StyledText>,
pub action: Option<HeaderAction>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InlineInput {
pub id: WidgetId,
pub text: String,
#[serde(default)]
pub caret: usize,
pub placeholder: Option<String>,
#[serde(default)]
pub has_focus: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum SectionAux {
Input(InlineInput),
Toolbar(Vec<HeaderAction>),
Search(InlineInput),
Custom(WidgetId),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum SectionBody {
Tree(TreeView),
List(ListView),
Form(Form),
Terminal(Terminal),
MessageList(MessageList),
Text(Vec<StyledText>),
Chart(Chart),
Empty(EmptyBody),
Custom(WidgetId),
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Section {
pub id: SectionId,
pub header: SectionHeader,
pub body: SectionBody,
#[serde(default)]
pub aux: Option<SectionAux>,
pub size: SectionSize,
#[serde(default)]
pub collapsed: bool,
#[serde(default)]
pub min_size: Option<u16>,
#[serde(default)]
pub max_size: Option<u16>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MultiSectionView {
pub id: WidgetId,
pub sections: Vec<Section>,
#[serde(default)]
pub active_section: Option<usize>,
pub axis: Axis,
#[serde(default)]
pub allow_resize: bool,
#[serde(default = "default_true")]
pub allow_collapse: bool,
pub scroll_mode: ScrollMode,
#[serde(default)]
pub has_focus: bool,
#[serde(default)]
pub panel_scroll: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SectionLayout {
pub section_idx: usize,
pub header_bounds: Rect,
pub aux_bounds: Option<Rect>,
pub body_bounds: Rect,
pub scrollbar_bounds: Option<Rect>,
pub thumb_bounds: Option<Rect>,
pub collapsed: bool,
pub resolved_size: f32,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct DividerBounds {
pub above: usize,
pub below: usize,
pub bounds: Rect,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MultiSectionViewHit {
Header { section: usize, kind: HeaderHit },
Aux { section: usize, kind: AuxHit },
Body { section: usize },
Divider { above: usize, below: usize },
Scrollbar { section: usize, kind: ScrollbarHit },
PanelScrollbar { kind: ScrollbarHit },
Inert,
Outside,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HeaderHit {
Chevron,
TitleArea,
Action(ActionId),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AuxHit {
Input,
Action(ActionId),
Custom,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ScrollbarHit {
Thumb,
TrackBefore,
TrackAfter,
}
#[derive(Debug, Clone, PartialEq)]
pub struct MultiSectionViewLayout {
pub bounds: Rect,
pub axis: Axis,
pub scroll_mode: ScrollMode,
pub sections: Vec<SectionLayout>,
pub dividers: Vec<DividerBounds>,
pub panel_scrollbar: Option<Rect>,
pub panel_scrollbar_thumb: Option<Rect>,
pub hit_regions: Vec<(Rect, MultiSectionViewHit)>,
}
impl MultiSectionViewLayout {
pub fn hit_test(&self, x: f32, y: f32) -> MultiSectionViewHit {
if !self.bounds.contains(crate::event::Point { x, y }) {
return MultiSectionViewHit::Outside;
}
for (rect, hit) in &self.hit_regions {
if x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height {
return hit.clone();
}
}
MultiSectionViewHit::Inert
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SectionMeasure {
pub content_size: f32,
pub aux_size: f32,
}
impl Default for SectionMeasure {
fn default() -> Self {
Self {
content_size: 0.0,
aux_size: 0.0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct MsvLayoutMetrics {
pub header_size: f32,
pub divider_size: f32,
pub scrollbar_size: f32,
pub cell_quantum: f32,
}
impl Default for MsvLayoutMetrics {
fn default() -> Self {
Self {
header_size: 1.0,
divider_size: 0.0,
scrollbar_size: 1.0,
cell_quantum: 0.0,
}
}
}
impl MultiSectionView {
pub fn layout<F>(
&self,
bounds: Rect,
metrics: MsvLayoutMetrics,
measure: F,
) -> MultiSectionViewLayout
where
F: Fn(usize) -> SectionMeasure,
{
match self.axis {
Axis::Vertical => self.layout_vertical(bounds, metrics, measure),
Axis::Horizontal => {
MultiSectionViewLayout {
bounds,
axis: self.axis,
scroll_mode: self.scroll_mode,
sections: Vec::new(),
dividers: Vec::new(),
panel_scrollbar: None,
panel_scrollbar_thumb: None,
hit_regions: Vec::new(),
}
}
}
}
fn layout_vertical<F>(
&self,
bounds: Rect,
metrics: MsvLayoutMetrics,
measure: F,
) -> MultiSectionViewLayout
where
F: Fn(usize) -> SectionMeasure,
{
let n = self.sections.len();
if n == 0 {
return MultiSectionViewLayout {
bounds,
axis: Axis::Vertical,
scroll_mode: self.scroll_mode,
sections: Vec::new(),
dividers: Vec::new(),
panel_scrollbar: None,
panel_scrollbar_thumb: None,
hit_regions: Vec::new(),
};
}
let measures: Vec<SectionMeasure> = (0..n).map(&measure).collect();
let divider_count = if metrics.divider_size > 0.0 && n > 1 {
(n - 1) as f32
} else {
0.0
};
let dividers_total = metrics.divider_size * divider_count;
let usable_main = (bounds.height - dividers_total).max(0.0);
let mut chrome_cost: Vec<f32> = vec![0.0; n];
for i in 0..n {
let aux = if self.sections[i].aux.is_some() {
measures[i].aux_size
} else {
0.0
};
let chrome = if self.sections[i].collapsed {
metrics.header_size
} else {
metrics.header_size + aux
};
chrome_cost[i] = chrome;
}
let mut resolved = match self.scroll_mode {
ScrollMode::WholePanel => {
let mut sizes = Vec::with_capacity(n);
for i in 0..n {
let body = if self.sections[i].collapsed {
0.0
} else {
measures[i].content_size
};
let total = chrome_cost[i] + body;
sizes.push(apply_min_max(&self.sections[i], total));
}
sizes
}
ScrollMode::PerSection => resolve_per_section_sizes(
&self.sections,
&measures,
&chrome_cost,
bounds.height,
usable_main,
),
};
if metrics.cell_quantum > 0.0 && n > 0 {
let q = metrics.cell_quantum;
let target_total: f32 = resolved.iter().sum();
let target_cells = (target_total / q).round() as i32;
let mut snapped: Vec<i32> = resolved.iter().map(|s| (s / q).floor() as i32).collect();
let mut remainder = target_cells - snapped.iter().sum::<i32>();
let mut order: Vec<(usize, f32)> = resolved
.iter()
.enumerate()
.map(|(i, s)| (i, (s / q) - (s / q).floor()))
.collect();
order.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
let mut cursor = 0;
while remainder > 0 && !order.is_empty() {
let (idx, _) = order[cursor % order.len()];
snapped[idx] += 1;
remainder -= 1;
cursor += 1;
}
if remainder < 0 {
let mut order_rev = order;
order_rev.reverse();
let mut c = 0;
while remainder < 0 && !order_rev.is_empty() {
let (idx, _) = order_rev[c % order_rev.len()];
if snapped[idx] > 1 {
snapped[idx] -= 1;
remainder += 1;
}
c += 1;
}
}
resolved = snapped.into_iter().map(|s| (s as f32) * q).collect();
}
let mut sections_out = Vec::with_capacity(n);
let mut dividers = Vec::new();
let mut hit_regions: Vec<(Rect, MultiSectionViewHit)> = Vec::new();
let panel_sb_w = match self.scroll_mode {
ScrollMode::WholePanel => {
let total: f32 = resolved.iter().sum::<f32>() + dividers_total;
if total > bounds.height {
metrics.scrollbar_size
} else {
0.0
}
}
ScrollMode::PerSection => 0.0,
};
let scroll_offset = match self.scroll_mode {
ScrollMode::WholePanel => {
let total: f32 = resolved.iter().sum::<f32>() + dividers_total;
let max_scroll = (total - bounds.height).max(0.0);
self.panel_scroll.clamp(0.0, max_scroll)
}
ScrollMode::PerSection => 0.0,
};
let mut y = bounds.y - scroll_offset;
for i in 0..n {
let s_main = resolved[i];
let s_top = y;
let header_bounds = Rect::new(
bounds.x,
s_top,
(bounds.width - panel_sb_w).max(0.0),
metrics.header_size,
);
let mut content_top = s_top + metrics.header_size;
let aux_bounds = if let Some(aux) = &self.sections[i].aux {
if self.sections[i].collapsed {
None
} else {
let aux_h = measures[i].aux_size;
let r = Rect::new(bounds.x, content_top, bounds.width, aux_h);
content_top += aux_h;
let _ = aux; Some(r)
}
} else {
None
};
let collapsed = self.sections[i].collapsed;
let remaining_main = (s_top + s_main - content_top).max(0.0);
let body_main = if collapsed { 0.0 } else { remaining_main };
let needs_scrollbar = matches!(self.scroll_mode, ScrollMode::PerSection)
&& !collapsed
&& body_overflows(&self.sections[i], measures[i].content_size, body_main);
let scrollbar_w = if needs_scrollbar {
metrics.scrollbar_size
} else {
0.0
};
let body_w = (bounds.width - scrollbar_w - panel_sb_w).max(0.0);
let body_bounds = Rect::new(bounds.x, content_top, body_w, body_main);
let scrollbar_bounds = if needs_scrollbar {
Some(Rect::new(
bounds.x + body_w,
content_top,
scrollbar_w,
body_main,
))
} else {
None
};
let thumb_bounds = match (scrollbar_bounds, body_scroll_state(&self.sections[i].body)) {
(Some(sb), Some((scroll_rows, row_count))) if row_count > 0 => {
Some(compute_thumb_bounds(
sb,
scroll_rows,
row_count,
measures[i].content_size,
metrics.cell_quantum,
))
}
_ => None,
};
sections_out.push(SectionLayout {
section_idx: i,
header_bounds,
aux_bounds,
body_bounds,
scrollbar_bounds,
thumb_bounds,
collapsed,
resolved_size: s_main,
});
push_header_hits(&self.sections[i].header, header_bounds, i, &mut hit_regions);
if let (Some(aux), Some(ar)) = (&self.sections[i].aux, aux_bounds) {
if !collapsed {
push_aux_hits(aux, ar, i, &mut hit_regions);
}
}
if !collapsed && body_bounds.height > 0.0 && body_bounds.width > 0.0 {
hit_regions.push((body_bounds, MultiSectionViewHit::Body { section: i }));
}
if let Some(sb) = scrollbar_bounds {
if let Some(thumb) = thumb_bounds {
hit_regions.push((
thumb,
MultiSectionViewHit::Scrollbar {
section: i,
kind: ScrollbarHit::Thumb,
},
));
let above_h = (thumb.y - sb.y).max(0.0);
if above_h > 0.0 {
let above = Rect::new(sb.x, sb.y, sb.width, above_h);
hit_regions.push((
above,
MultiSectionViewHit::Scrollbar {
section: i,
kind: ScrollbarHit::TrackBefore,
},
));
}
let below_y = thumb.y + thumb.height;
let below_h = (sb.y + sb.height - below_y).max(0.0);
if below_h > 0.0 {
let below = Rect::new(sb.x, below_y, sb.width, below_h);
hit_regions.push((
below,
MultiSectionViewHit::Scrollbar {
section: i,
kind: ScrollbarHit::TrackAfter,
},
));
}
} else {
hit_regions.push((
sb,
MultiSectionViewHit::Scrollbar {
section: i,
kind: ScrollbarHit::Thumb,
},
));
}
}
y += s_main;
if metrics.divider_size > 0.0 && i + 1 < n && self.allow_resize {
let d = Rect::new(bounds.x, y, bounds.width, metrics.divider_size);
dividers.push(DividerBounds {
above: i,
below: i + 1,
bounds: d,
});
hit_regions.push((
d,
MultiSectionViewHit::Divider {
above: i,
below: i + 1,
},
));
y += metrics.divider_size;
}
}
let (panel_scrollbar, panel_scrollbar_thumb) = match self.scroll_mode {
ScrollMode::WholePanel => {
let total_content: f32 = resolved.iter().sum::<f32>() + dividers_total;
if total_content > bounds.height {
let sb_w = metrics.scrollbar_size;
let r = Rect::new(
bounds.x + bounds.width - sb_w,
bounds.y,
sb_w,
bounds.height,
);
let min_thumb = panel_thumb_min(&metrics);
let (thumb_start, thumb_h) = crate::primitives::scrollbar::fit_thumb(
self.panel_scroll,
total_content,
bounds.height,
r.height,
min_thumb,
);
let thumb_y = r.y + thumb_start;
if thumb_y > r.y {
hit_regions.push((
Rect::new(r.x, r.y, r.width, thumb_y - r.y),
MultiSectionViewHit::PanelScrollbar {
kind: ScrollbarHit::TrackBefore,
},
));
}
let thumb_rect = Rect::new(r.x, thumb_y, r.width, thumb_h);
hit_regions.push((
thumb_rect,
MultiSectionViewHit::PanelScrollbar {
kind: ScrollbarHit::Thumb,
},
));
let thumb_bottom = thumb_y + thumb_h;
let track_bottom = r.y + r.height;
if thumb_bottom < track_bottom {
hit_regions.push((
Rect::new(r.x, thumb_bottom, r.width, track_bottom - thumb_bottom),
MultiSectionViewHit::PanelScrollbar {
kind: ScrollbarHit::TrackAfter,
},
));
}
(Some(r), Some(thumb_rect))
} else {
(None, None)
}
}
ScrollMode::PerSection => (None, None),
};
MultiSectionViewLayout {
bounds,
axis: Axis::Vertical,
scroll_mode: self.scroll_mode,
sections: sections_out,
dividers,
panel_scrollbar,
panel_scrollbar_thumb,
hit_regions,
}
}
pub fn resize_divider(
&mut self,
above: usize,
below: usize,
current_above: f32,
current_below: f32,
delta: f32,
) -> bool {
if above >= self.sections.len() || below >= self.sections.len() {
return false;
}
let (lo_above, hi_above) = size_bounds(&self.sections[above]);
let (lo_below, hi_below) = size_bounds(&self.sections[below]);
let max_grow_above = hi_above - current_above;
let max_shrink_above = current_above - lo_above;
let max_grow_below = hi_below - current_below;
let max_shrink_below = current_below - lo_below;
let mut d = delta;
if d > 0.0 {
d = d.min(max_grow_above).min(max_shrink_below);
} else if d < 0.0 {
d = (-d).min(max_shrink_above).min(max_grow_below).neg();
}
if d == 0.0 {
return false;
}
let new_above = (current_above + d).round() as u16;
let new_below = (current_below - d).round() as u16;
self.sections[above].size = SectionSize::Fixed(new_above);
self.sections[below].size = SectionSize::Fixed(new_below);
true
}
}
trait FloatNeg {
fn neg(self) -> Self;
}
impl FloatNeg for f32 {
fn neg(self) -> Self {
-self
}
}
fn apply_min_max(s: &Section, raw: f32) -> f32 {
let mut v = raw;
if let Some(min) = s.min_size {
v = v.max(min as f32);
}
if let Some(max) = s.max_size {
v = v.min(max as f32);
}
v
}
fn size_bounds(s: &Section) -> (f32, f32) {
let lo = s.min_size.map(|m| m as f32).unwrap_or(0.0);
let hi = s.max_size.map(|m| m as f32).unwrap_or(f32::INFINITY);
(lo, hi)
}
pub(crate) fn panel_thumb_min(metrics: &MsvLayoutMetrics) -> f32 {
if metrics.cell_quantum > 0.0 {
metrics.cell_quantum
} else {
metrics.scrollbar_size.max(8.0)
}
}
fn body_overflows(s: &Section, content: f32, body_main: f32) -> bool {
match &s.body {
SectionBody::Empty(_) | SectionBody::Custom(_) => false,
_ => content > body_main + 0.5, }
}
fn body_scroll_state(body: &SectionBody) -> Option<(usize, usize)> {
match body {
SectionBody::Tree(t) => Some((t.scroll_offset, t.rows.len())),
SectionBody::List(l) => {
let title_row = if l.title.is_some() { 1 } else { 0 };
Some((l.scroll_offset, l.items.len() + title_row))
}
_ => None,
}
}
pub(crate) fn quantize_thumb(
thumb_start: f32,
thumb_len: f32,
track_len: f32,
cell_quantum: f32,
) -> (f32, f32) {
if cell_quantum <= 0.0 {
return (thumb_start, thumb_len);
}
let q = cell_quantum;
let start_q = (thumb_start / q).floor() as i32;
let end_q = ((thumb_start + thumb_len) / q).ceil() as i32;
let max_end_q = (track_len / q).round() as i32;
let end_q = end_q.min(max_end_q);
let len_q = (end_q - start_q).max(1);
let start_q = start_q.min(max_end_q - len_q).max(0);
(start_q as f32 * q, len_q as f32 * q)
}
fn compute_thumb_bounds(
gutter: Rect,
scroll_rows: usize,
row_count: usize,
content_size: f32,
cell_quantum: f32,
) -> Rect {
if row_count == 0 || gutter.height <= 0.0 || content_size <= 0.0 {
return gutter;
}
let row_main_unit = content_size / row_count as f32;
let scroll_main = scroll_rows as f32 * row_main_unit;
let visible = gutter.height; let track_len = gutter.height;
let min_thumb_len = if cell_quantum > 0.0 {
cell_quantum
} else {
1.0
};
let (thumb_start, thumb_len) = crate::primitives::scrollbar::fit_thumb(
scroll_main,
content_size,
visible,
track_len,
min_thumb_len,
);
let (thumb_start, thumb_len) = quantize_thumb(thumb_start, thumb_len, track_len, cell_quantum);
Rect::new(gutter.x, gutter.y + thumb_start, gutter.width, thumb_len)
}
fn resolve_per_section_sizes(
sections: &[Section],
measures: &[SectionMeasure],
chrome_cost: &[f32],
container_main: f32,
usable_main: f32,
) -> Vec<f32> {
let n = sections.len();
let mut out = vec![0.0_f32; n];
let mut consumed = 0.0_f32;
let mut pass1_done = vec![false; n];
for i in 0..n {
if sections[i].collapsed {
out[i] = apply_min_max(§ions[i], chrome_cost[i]);
consumed += out[i];
pass1_done[i] = true;
continue;
}
match sections[i].size {
SectionSize::Fixed(rows) => {
let v = apply_min_max(§ions[i], rows as f32);
out[i] = v;
consumed += v;
pass1_done[i] = true;
}
SectionSize::Content => {
let raw = chrome_cost[i] + measures[i].content_size;
let v = apply_min_max(§ions[i], raw);
out[i] = v;
consumed += v;
pass1_done[i] = true;
}
_ => {}
}
}
let mut percent_indices: Vec<usize> = Vec::new();
let mut percent_raw: Vec<f32> = Vec::new();
for i in 0..n {
if pass1_done[i] {
continue;
}
if let SectionSize::Percent(p) = sections[i].size {
percent_indices.push(i);
percent_raw.push(p.clamp(0.0, 1.0) * container_main);
}
}
let percent_total: f32 = percent_raw.iter().sum();
let percent_budget = (usable_main - consumed).max(0.0);
let percent_scale = if percent_total > percent_budget && percent_total > 0.0 {
percent_budget / percent_total
} else {
1.0
};
for (k, &i) in percent_indices.iter().enumerate() {
let raw = percent_raw[k] * percent_scale;
let v = apply_min_max(§ions[i], raw.max(chrome_cost[i]));
out[i] = v;
consumed += v;
pass1_done[i] = true;
}
let remainder = (usable_main - consumed).max(0.0);
let mut flex_indices: Vec<usize> = Vec::new();
let mut weights: Vec<f32> = Vec::new();
for i in 0..n {
if pass1_done[i] {
continue;
}
match sections[i].size {
SectionSize::Weight(w) => {
flex_indices.push(i);
weights.push(w.max(0.0));
}
SectionSize::EqualShare => {
flex_indices.push(i);
weights.push(1.0);
}
_ => {}
}
}
let weight_sum: f32 = weights.iter().sum();
if !flex_indices.is_empty() && weight_sum > 0.0 {
for (k, &i) in flex_indices.iter().enumerate() {
let raw = remainder * (weights[k] / weight_sum);
let v = apply_min_max(§ions[i], raw.max(chrome_cost[i]));
out[i] = v;
}
} else {
for &i in &flex_indices {
out[i] = apply_min_max(§ions[i], chrome_cost[i]);
}
}
out
}
fn push_header_hits(
header: &SectionHeader,
bounds: Rect,
section: usize,
out: &mut Vec<(Rect, MultiSectionViewHit)>,
) {
let chevron_w = if header.show_chevron { 2.0 } else { 0.0 };
let action_w = 2.0;
let mut right_cursor = bounds.x + bounds.width;
for action in header.actions.iter().rev() {
if !action.enabled {
continue;
}
let r = Rect::new(right_cursor - action_w, bounds.y, action_w, bounds.height);
out.push((
r,
MultiSectionViewHit::Header {
section,
kind: HeaderHit::Action(action.id.clone()),
},
));
right_cursor -= action_w;
}
if header.show_chevron {
let r = Rect::new(bounds.x, bounds.y, chevron_w, bounds.height);
out.push((
r,
MultiSectionViewHit::Header {
section,
kind: HeaderHit::Chevron,
},
));
}
let title_x = bounds.x + chevron_w;
let title_w = (right_cursor - title_x).max(0.0);
if title_w > 0.0 {
let r = Rect::new(title_x, bounds.y, title_w, bounds.height);
out.push((
r,
MultiSectionViewHit::Header {
section,
kind: HeaderHit::TitleArea,
},
));
}
}
fn push_aux_hits(
aux: &SectionAux,
bounds: Rect,
section: usize,
out: &mut Vec<(Rect, MultiSectionViewHit)>,
) {
match aux {
SectionAux::Input(_) | SectionAux::Search(_) => {
out.push((
bounds,
MultiSectionViewHit::Aux {
section,
kind: AuxHit::Input,
},
));
}
SectionAux::Toolbar(actions) => {
let action_w = 2.0;
let mut x = bounds.x;
for a in actions {
if !a.enabled {
x += action_w;
continue;
}
let r = Rect::new(x, bounds.y, action_w, bounds.height);
out.push((
r,
MultiSectionViewHit::Aux {
section,
kind: AuxHit::Action(a.id.clone()),
},
));
x += action_w;
}
}
SectionAux::Custom(_) => {
out.push((
bounds,
MultiSectionViewHit::Aux {
section,
kind: AuxHit::Custom,
},
));
}
}
}
#[cfg(any(
feature = "gtk",
feature = "win",
all(feature = "macos", target_os = "macos")
))]
#[allow(dead_code)]
pub(crate) mod native_surface_paint {
use super::{EmptyBody, SectionAux, SectionHeader};
use crate::event::Rect;
use crate::paint_surface::PaintSurface;
use crate::theme::Theme;
use crate::types::{Color, StyledText};
fn plain_text(t: &StyledText) -> String {
t.spans.iter().map(|s| s.text.as_str()).collect()
}
pub(crate) fn paint_header(
surface: &mut dyn PaintSurface,
bounds: Rect,
header: &SectionHeader,
collapsed: bool,
theme: &Theme,
) {
surface.surface_fill_rect(bounds, theme.header_bg);
let mut left_x = bounds.x + 4.0;
let row_text_y = |th: f32| (bounds.y + (bounds.height - th) * 0.4).round();
if header.show_chevron {
let chevron = if collapsed { "\u{25B8}" } else { "\u{25BE}" };
let (cw, ch) = surface.surface_measure_text(chevron);
surface.surface_draw_text_run(
Rect::new(left_x, row_text_y(ch), cw, ch),
chevron,
theme.header_fg,
);
left_x += cw + 4.0;
}
let mut right_x = bounds.x + bounds.width - 4.0;
for action in header.actions.iter().rev() {
let glyph = action.icon.fallback.as_str();
let (gw, gh) = surface.surface_measure_text(glyph);
right_x -= gw;
if right_x < left_x {
break;
}
let action_fg = if action.enabled {
theme.header_fg
} else {
theme.muted_fg
};
surface.surface_draw_text_run(
Rect::new(right_x, row_text_y(gh), gw, gh),
glyph,
action_fg,
);
right_x -= 8.0;
}
let title_text = plain_text(&header.title);
if !title_text.is_empty() {
let (tw, th) = surface.surface_measure_text(&title_text);
let max_w = (right_x - left_x).max(0.0);
if max_w > 0.0 {
surface.surface_push_clip(Rect::new(left_x, bounds.y, max_w, bounds.height));
surface.surface_draw_text_run(
Rect::new(left_x, row_text_y(th), tw, th),
&title_text,
theme.header_fg,
);
surface.surface_pop_clip();
let after_title_x = left_x + tw.min(max_w);
if let Some(badge) = &header.badge {
let badge_text = plain_text(badge);
if !badge_text.is_empty() {
let badge_x = after_title_x + 6.0;
if badge_x < right_x {
let (bw, bth) = surface.surface_measure_text(&badge_text);
surface.surface_draw_text_run(
Rect::new(badge_x, bounds.y + (bounds.height - bth) / 2.0, bw, bth),
&badge_text,
theme.muted_fg,
);
}
}
}
}
}
}
pub(crate) fn paint_aux(
surface: &mut dyn PaintSurface,
bounds: Rect,
aux: &SectionAux,
theme: &Theme,
caret_visible: bool,
) {
surface.surface_fill_rect(bounds, theme.input_bg);
match aux {
SectionAux::Input(input) | SectionAux::Search(input) => {
let display: &str = if input.text.is_empty() && !input.has_focus {
input.placeholder.as_deref().unwrap_or("")
} else {
input.text.as_str()
};
let text_fg = if input.text.is_empty() && !input.has_focus {
theme.muted_fg
} else {
theme.foreground
};
let (dw, dh) = surface.surface_measure_text(display);
surface.surface_draw_text_run(
Rect::new(
bounds.x + 4.0,
bounds.y + (bounds.height - dh) / 2.0,
dw,
dh,
),
display,
text_fg,
);
if input.has_focus && caret_visible {
let prefix: String = input.text.chars().take(input.caret).collect();
let (cx_off, _) = surface.surface_measure_text(&prefix);
let caret_x = bounds.x + 4.0 + cx_off;
surface.surface_fill_rect(
Rect::new(caret_x, bounds.y + 2.0, 1.0, bounds.height - 4.0),
theme.foreground,
);
}
}
SectionAux::Toolbar(actions) => {
let mut tx = bounds.x + 4.0;
for a in actions {
let glyph = a.icon.fallback.as_str();
let action_fg = if a.enabled {
theme.foreground
} else {
theme.muted_fg
};
let (gw, gh) = surface.surface_measure_text(glyph);
surface.surface_draw_text_run(
Rect::new(tx, bounds.y + (bounds.height - gh) / 2.0, gw, gh),
glyph,
action_fg,
);
tx += gw + 8.0;
}
}
SectionAux::Custom(_) => {
}
}
}
pub(crate) fn paint_text_lines(
surface: &mut dyn PaintSurface,
bounds: Rect,
lines: &[StyledText],
theme: &Theme,
line_height: f32,
) {
surface.surface_fill_rect(bounds, theme.background);
let mut row_y = bounds.y;
for line in lines {
if row_y + line_height > bounds.y + bounds.height {
break;
}
let text = plain_text(line);
let (tw, th) = surface.surface_measure_text(&text);
surface.surface_draw_text_run(
Rect::new(bounds.x + 4.0, row_y + (line_height - th) / 2.0, tw, th),
&text,
theme.foreground,
);
row_y += line_height;
}
}
pub(crate) fn paint_empty_body(
surface: &mut dyn PaintSurface,
bounds: Rect,
empty: &EmptyBody,
theme: &Theme,
line_height: f32,
) {
surface.surface_fill_rect(bounds, theme.background);
if bounds.width <= 0.0 || bounds.height <= 0.0 {
return;
}
enum EmptyBlock {
Text(String, Color),
Button(String),
}
let mut blocks: Vec<EmptyBlock> = Vec::new();
if let Some(icon) = &empty.icon {
blocks.push(EmptyBlock::Text(icon.fallback.clone(), theme.foreground));
}
let primary = plain_text(&empty.text);
if !primary.is_empty() {
blocks.push(EmptyBlock::Text(primary, theme.foreground));
}
if let Some(hint) = &empty.hint {
let hint_str = plain_text(hint);
if !hint_str.is_empty() {
blocks.push(EmptyBlock::Text(hint_str, theme.muted_fg));
}
}
if let Some(action) = &empty.action {
let label = action
.tooltip
.clone()
.unwrap_or_else(|| action.icon.fallback.clone());
blocks.push(EmptyBlock::Button(label));
}
if blocks.is_empty() {
return;
}
const BUTTON_PAD_X: f32 = 12.0;
let total_h = blocks.len() as f32 * line_height;
let mut block_y = bounds.y + (bounds.height - total_h).max(0.0) / 2.0;
for block in &blocks {
match block {
EmptyBlock::Text(text, color) => {
let (tw, th) = surface.surface_measure_text(text);
let block_x = bounds.x + (bounds.width - tw).max(0.0) / 2.0;
surface.surface_draw_text_run(
Rect::new(block_x, block_y + (line_height - th) / 2.0, tw, th),
text,
*color,
);
}
EmptyBlock::Button(label) => {
let (tw, th) = surface.surface_measure_text(label);
let btn_w = tw + BUTTON_PAD_X * 2.0;
let btn_h = (line_height - 4.0).max(th + 4.0);
let btn_x = bounds.x + (bounds.width - btn_w).max(0.0) / 2.0;
let btn_y = block_y + (line_height - btn_h) / 2.0;
let btn_rect = Rect::new(btn_x, btn_y, btn_w, btn_h);
surface.surface_stroke_rect(btn_rect, theme.accent_fg, 1.0);
surface.surface_draw_text_run(
Rect::new(btn_x + BUTTON_PAD_X, btn_y + (btn_h - th) / 2.0, tw, th),
label,
theme.accent_fg,
);
}
}
block_y += line_height;
}
}
pub(crate) fn paint_scrollbar(
surface: &mut dyn PaintSurface,
gutter: Rect,
thumb_bounds: Option<Rect>,
theme: &Theme,
) {
surface.surface_fill_rect(gutter, theme.scrollbar_track.with_alpha(0.5));
let (ty, th) = match thumb_bounds {
Some(t) => (t.y, t.height.max(1.0)),
None => (gutter.y, (gutter.height * 0.2).max(20.0).min(gutter.height)),
};
surface.surface_fill_rect(
Rect::new(gutter.x, ty, gutter.width, th),
theme.scrollbar_thumb.with_alpha(0.9),
);
}
pub(crate) fn paint_panel_scrollbar(
surface: &mut dyn PaintSurface,
bounds: Rect,
thumb_bounds: Option<Rect>,
theme: &Theme,
) {
if bounds.height <= 0.0 {
return;
}
surface.surface_fill_rect(bounds, theme.scrollbar_track);
let (thumb_y, thumb_h) = match thumb_bounds {
Some(t) => (t.y, t.height.max(1.0)),
None => (bounds.y, bounds.height),
};
surface.surface_fill_rect(
Rect::new(bounds.x, thumb_y, bounds.width, thumb_h),
theme.scrollbar_thumb,
);
}
pub(crate) fn paint_divider(surface: &mut dyn PaintSurface, bounds: Rect, theme: &Theme) {
surface.surface_fill_rect(bounds, theme.separator);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::ImagePaintResult;
use crate::event::Viewport;
use crate::primitives::multi_section_view::{HeaderAction, InlineInput};
use crate::types::WidgetId;
use crate::Image;
#[derive(Default)]
struct RecordingSurface {
fills: Vec<(Rect, Color)>,
texts: Vec<(Rect, String, Color, usize)>,
clips: Vec<Rect>,
strokes: Vec<(Rect, Color, f32)>,
}
impl PaintSurface for RecordingSurface {
fn surface_begin_frame(&mut self, _viewport: Viewport) {}
fn surface_end_frame(&mut self) {}
fn surface_viewport(&self) -> Viewport {
Viewport::new(200.0, 100.0, 1.0)
}
fn surface_line_height(&self) -> f32 {
16.0
}
fn surface_char_width(&self) -> f32 {
8.0
}
fn surface_measure_text(&self, text: &str) -> (f32, f32) {
(text.chars().count() as f32 * 8.0, 14.0)
}
fn surface_fill_rect(&mut self, rect: Rect, color: Color) {
self.fills.push((rect, color));
}
fn surface_fill_rounded_rect(&mut self, rect: Rect, _radius: f32, color: Color) {
self.fills.push((rect, color));
}
fn surface_stroke_rect(&mut self, rect: Rect, color: Color, stroke_width: f32) {
self.strokes.push((rect, color, stroke_width));
}
fn surface_stroke_rounded_rect(
&mut self,
_rect: Rect,
_radius: f32,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_draw_text_run(&mut self, rect: Rect, text: &str, color: Color) {
self.texts
.push((rect, text.to_string(), color, self.clips.len()));
}
fn surface_draw_line(
&mut self,
_from: crate::Point,
_to: crate::Point,
_color: Color,
_stroke_width: f32,
) {
}
fn surface_push_clip(&mut self, rect: Rect) {
self.clips.push(rect);
}
fn surface_pop_clip(&mut self) {
self.clips.pop();
}
fn surface_draw_image(&mut self, _rect: Rect, _image: &Image) -> ImagePaintResult {
ImagePaintResult::Unsupported
}
}
fn header_with(title: &str, actions: Vec<HeaderAction>) -> SectionHeader {
SectionHeader {
icon: None,
title: StyledText::plain(title),
badge: None,
actions,
show_chevron: false,
}
}
#[test]
fn paint_header_clips_the_title_draw() {
let header = header_with("a very long title indeed", vec![]);
let mut surface = RecordingSurface::default();
paint_header(
&mut surface,
Rect::new(0.0, 0.0, 120.0, 20.0),
&header,
false,
&Theme::default(),
);
let title_draw = surface
.texts
.iter()
.find(|(_, text, ..)| text == "a very long title indeed")
.expect("title must be drawn");
assert_eq!(
title_draw.3, 1,
"title draw must happen with exactly one clip active, got depth {}",
title_draw.3
);
assert!(
surface.clips.is_empty(),
"every pushed clip must be popped by the time paint_header returns"
);
}
#[test]
fn paint_header_skips_clip_when_title_is_empty() {
let header = header_with("", vec![]);
let mut surface = RecordingSurface::default();
paint_header(
&mut surface,
Rect::new(0.0, 0.0, 120.0, 20.0),
&header,
false,
&Theme::default(),
);
assert!(surface.clips.is_empty());
assert!(surface.texts.is_empty());
}
#[test]
fn scrollbar_track_and_thumb_paint_with_real_alpha() {
let mut surface = RecordingSurface::default();
paint_scrollbar(
&mut surface,
Rect::new(0.0, 0.0, 8.0, 100.0),
Some(Rect::new(0.0, 10.0, 8.0, 20.0)),
&Theme::default(),
);
assert_eq!(surface.fills.len(), 2);
for (_, color) in &surface.fills {
assert!(color.a < 255, "expected real alpha, got a={}", color.a);
}
}
#[test]
fn panel_scrollbar_uses_supplied_thumb_bounds_not_a_recomputed_one() {
let mut surface = RecordingSurface::default();
let thumb = Rect::new(0.0, 37.0, 8.0, 15.0);
paint_panel_scrollbar(
&mut surface,
Rect::new(0.0, 0.0, 8.0, 100.0),
Some(thumb),
&Theme::default(),
);
let thumb_fill = surface
.fills
.iter()
.find(|(r, _)| {
(r.y - thumb.y).abs() < 0.01 && (r.height - thumb.height).abs() < 0.01
})
.expect("thumb must paint at the exact supplied thumb_bounds");
assert_eq!(thumb_fill.0, thumb);
}
#[test]
fn empty_body_with_no_content_paints_only_background() {
let mut surface = RecordingSurface::default();
paint_empty_body(
&mut surface,
Rect::new(0.0, 0.0, 100.0, 100.0),
&EmptyBody::default(),
&Theme::default(),
16.0,
);
assert_eq!(surface.fills.len(), 1, "only the background fill");
assert!(surface.texts.is_empty());
}
#[test]
fn empty_body_action_paints_a_stroked_button_not_bracket_text() {
let mut surface = RecordingSurface::default();
let empty = EmptyBody {
icon: None,
text: StyledText::plain("No results"),
hint: None,
action: Some(HeaderAction {
id: crate::primitives::multi_section_view::ActionId::from("open-folder"),
icon: crate::types::Icon::new("", "Open"),
tooltip: Some("Open Folder".to_string()),
enabled: true,
}),
};
paint_empty_body(
&mut surface,
Rect::new(0.0, 0.0, 200.0, 100.0),
&empty,
&Theme::default(),
16.0,
);
assert!(
!surface
.texts
.iter()
.any(|(_, text, _, _)| text.contains('[')),
"no bracket glyph should be painted for the action button; \
texts were {:?}",
surface.texts,
);
assert!(
surface
.texts
.iter()
.any(|(_, text, _, _)| text == "Open Folder"),
"the action label itself must still be painted; texts were {:?}",
surface.texts,
);
assert_eq!(
surface.strokes.len(),
1,
"the action button must stroke exactly one bordering rect; \
strokes were {:?}",
surface.strokes,
);
}
#[test]
fn aux_caret_paints_only_when_focused_and_visible() {
let aux = SectionAux::Search(InlineInput {
id: WidgetId::new("q"),
text: String::new(),
caret: 0,
placeholder: None,
has_focus: true,
});
let bounds = Rect::new(0.0, 0.0, 100.0, 16.0);
let mut visible = RecordingSurface::default();
paint_aux(&mut visible, bounds, &aux, &Theme::default(), true);
assert_eq!(
visible.fills.len(),
2,
"expected background fill + caret fill"
);
let mut hidden = RecordingSurface::default();
paint_aux(&mut hidden, bounds, &aux, &Theme::default(), false);
assert_eq!(
hidden.fills.len(),
1,
"caret_visible=false must skip the caret fill"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::WidgetId;
fn empty_section(id: &str, size: SectionSize) -> Section {
Section {
id: id.into(),
header: SectionHeader {
title: StyledText::plain(id),
show_chevron: true,
..Default::default()
},
body: SectionBody::Empty(EmptyBody {
text: StyledText::plain("empty"),
..Default::default()
}),
aux: None,
size,
collapsed: false,
min_size: None,
max_size: None,
}
}
fn view(sections: Vec<Section>) -> MultiSectionView {
MultiSectionView {
id: WidgetId::new("view"),
sections,
active_section: None,
axis: Axis::Vertical,
allow_resize: false,
allow_collapse: true,
scroll_mode: ScrollMode::PerSection,
has_focus: false,
panel_scroll: 0.0,
}
}
#[test]
fn equal_share_two_sections_split_evenly() {
let v = view(vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 20.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert_eq!(layout.sections.len(), 2);
assert!((layout.sections[0].resolved_size - 10.0).abs() < 0.01);
assert!((layout.sections[1].resolved_size - 10.0).abs() < 0.01);
assert_eq!(layout.sections[0].header_bounds.y, 0.0);
assert_eq!(layout.sections[1].header_bounds.y, 10.0);
}
#[test]
fn fixed_then_equal_share_splits_remainder() {
let v = view(vec![
empty_section("fixed", SectionSize::Fixed(5)),
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 25.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert!((layout.sections[0].resolved_size - 5.0).abs() < 0.01);
assert!((layout.sections[1].resolved_size - 10.0).abs() < 0.01);
assert!((layout.sections[2].resolved_size - 10.0).abs() < 0.01);
}
#[test]
fn percent_allocates_against_original_container() {
let v = view(vec![
empty_section("p", SectionSize::Percent(0.4)),
empty_section("rest", SectionSize::EqualShare),
]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 100.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert!((layout.sections[0].resolved_size - 40.0).abs() < 0.01);
assert!((layout.sections[1].resolved_size - 60.0).abs() < 0.01);
}
#[test]
fn weight_distributes_2_to_1() {
let v = view(vec![
empty_section("a", SectionSize::Weight(2.0)),
empty_section("b", SectionSize::Weight(1.0)),
]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 30.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert!((layout.sections[0].resolved_size - 20.0).abs() < 0.01);
assert!((layout.sections[1].resolved_size - 10.0).abs() < 0.01);
}
#[test]
fn collapsed_section_uses_only_header_height() {
let mut sections = vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
];
sections[0].collapsed = true;
let v = view(sections);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 20.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert!((layout.sections[0].resolved_size - 1.0).abs() < 0.01);
assert!((layout.sections[1].resolved_size - 19.0).abs() < 0.01);
assert!(layout.sections[0].collapsed);
}
#[test]
fn min_size_floor_honoured_for_equal_share() {
let mut sections = vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
];
sections[0].min_size = Some(15);
let v = view(sections);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 20.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
assert!(layout.sections[0].resolved_size >= 15.0 - 0.01);
}
#[test]
fn header_hit_chevron_vs_title_vs_action() {
let header = SectionHeader {
title: StyledText::plain("Section"),
show_chevron: true,
actions: vec![HeaderAction {
id: "refresh".into(),
icon: Icon::new("", "R"),
tooltip: None,
enabled: true,
}],
..Default::default()
};
let v = view(vec![Section {
id: "s".into(),
header,
body: SectionBody::Empty(EmptyBody::default()),
aux: None,
size: SectionSize::Fixed(3),
collapsed: false,
min_size: None,
max_size: None,
}]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 10.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
match layout.hit_test(0.5, 0.5) {
MultiSectionViewHit::Header {
kind: HeaderHit::Chevron,
..
} => {}
other => panic!("expected Chevron, got {:?}", other),
}
match layout.hit_test(10.0, 0.5) {
MultiSectionViewHit::Header {
kind: HeaderHit::TitleArea,
..
} => {}
other => panic!("expected TitleArea, got {:?}", other),
}
match layout.hit_test(29.0, 0.5) {
MultiSectionViewHit::Header {
kind: HeaderHit::Action(id),
..
} => {
assert_eq!(id, "refresh");
}
other => panic!("expected Action, got {:?}", other),
}
}
#[test]
fn disabled_action_falls_through_to_title_area() {
let header = SectionHeader {
title: StyledText::plain("Section"),
show_chevron: false,
actions: vec![HeaderAction {
id: "noop".into(),
icon: Icon::new("", "x"),
tooltip: None,
enabled: false,
}],
..Default::default()
};
let v = view(vec![Section {
id: "s".into(),
header,
body: SectionBody::Empty(EmptyBody::default()),
aux: None,
size: SectionSize::Fixed(3),
collapsed: false,
min_size: None,
max_size: None,
}]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 10.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
match layout.hit_test(29.0, 0.5) {
MultiSectionViewHit::Header {
kind: HeaderHit::TitleArea,
..
} => {}
other => panic!("expected TitleArea, got {:?}", other),
}
}
#[test]
fn body_hit_returns_section_index() {
let v = view(vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 20.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
match layout.hit_test(15.0, 5.0) {
MultiSectionViewHit::Body { section } => assert_eq!(section, 0),
other => panic!("expected Body, got {:?}", other),
}
match layout.hit_test(15.0, 15.0) {
MultiSectionViewHit::Body { section } => assert_eq!(section, 1),
other => panic!("expected Body, got {:?}", other),
}
}
#[test]
fn outside_bounds_returns_outside() {
let v = view(vec![empty_section("a", SectionSize::EqualShare)]);
let layout = v.layout(
Rect::new(10.0, 10.0, 20.0, 20.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure::default(),
);
match layout.hit_test(0.0, 0.0) {
MultiSectionViewHit::Outside => {}
other => panic!("expected Outside, got {:?}", other),
}
}
#[test]
fn divider_only_emitted_when_resize_allowed() {
let mut v = view(vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
]);
let metrics = MsvLayoutMetrics {
divider_size: 1.0,
..MsvLayoutMetrics::default()
};
v.allow_resize = false;
let layout = v.layout(Rect::new(0.0, 0.0, 30.0, 20.0), metrics, |_| {
SectionMeasure::default()
});
assert!(layout.dividers.is_empty());
v.allow_resize = true;
let layout = v.layout(Rect::new(0.0, 0.0, 30.0, 20.0), metrics, |_| {
SectionMeasure::default()
});
assert_eq!(layout.dividers.len(), 1);
assert_eq!(layout.dividers[0].above, 0);
assert_eq!(layout.dividers[0].below, 1);
}
#[test]
fn divider_drag_clamped_by_min_max() {
let mut sections = vec![
empty_section("a", SectionSize::Fixed(10)),
empty_section("b", SectionSize::Fixed(10)),
];
sections[0].min_size = Some(5);
sections[1].min_size = Some(5);
let mut v = view(sections);
let changed = v.resize_divider(0, 1, 10.0, 10.0, 100.0);
assert!(changed);
match v.sections[0].size {
SectionSize::Fixed(n) => assert_eq!(n, 15),
_ => panic!("expected Fixed"),
}
match v.sections[1].size {
SectionSize::Fixed(n) => assert_eq!(n, 5),
_ => panic!("expected Fixed"),
}
}
#[test]
fn whole_panel_scroll_mode_no_per_section_scrollbars() {
let mut sections = vec![
empty_section("a", SectionSize::Content),
empty_section("b", SectionSize::Content),
];
sections[0].body = SectionBody::Text(vec![StyledText::plain("line1")]);
sections[1].body = SectionBody::Text(vec![StyledText::plain("line2")]);
let mut v = view(sections);
v.scroll_mode = ScrollMode::WholePanel;
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 4.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure {
content_size: 5.0,
aux_size: 0.0,
},
);
for s in &layout.sections {
assert!(s.scrollbar_bounds.is_none());
}
assert!(layout.panel_scrollbar.is_some());
}
#[test]
fn cell_quantum_snaps_section_bounds_to_integers() {
let v = view(vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
empty_section("c", SectionSize::EqualShare),
empty_section("d", SectionSize::EqualShare),
]);
let metrics = MsvLayoutMetrics {
cell_quantum: 1.0,
..MsvLayoutMetrics::default()
};
let layout = v.layout(Rect::new(0.0, 0.0, 30.0, 21.0), metrics, |_| {
SectionMeasure {
content_size: 3.0,
aux_size: 0.0,
}
});
for s in &layout.sections {
let hb = s.header_bounds;
assert_eq!(
hb.y,
hb.y.round(),
"section {} header y {} not integer-aligned",
s.section_idx,
hb.y
);
assert_eq!(
hb.height,
hb.height.round(),
"section {} header h {} not integer-aligned",
s.section_idx,
hb.height
);
let bb = s.body_bounds;
assert_eq!(
bb.y,
bb.y.round(),
"section {} body y {} not integer-aligned",
s.section_idx,
bb.y
);
assert_eq!(
bb.height,
bb.height.round(),
"section {} body h {} not integer-aligned",
s.section_idx,
bb.height
);
}
let total: f32 = layout.sections.iter().map(|s| s.resolved_size).sum();
assert_eq!(total, 21.0);
}
#[test]
fn cell_quantum_paint_and_hit_test_agree_on_every_row() {
let v = view(vec![
empty_section("a", SectionSize::EqualShare),
empty_section("b", SectionSize::EqualShare),
empty_section("c", SectionSize::EqualShare),
empty_section("d", SectionSize::EqualShare),
]);
let metrics = MsvLayoutMetrics {
cell_quantum: 1.0,
..MsvLayoutMetrics::default()
};
let layout = v.layout(Rect::new(0.0, 2.0, 30.0, 21.0), metrics, |_| {
SectionMeasure {
content_size: 5.0,
aux_size: 0.0,
}
});
for s in &layout.sections {
let painted_row = s.header_bounds.y.round();
let hit = layout.hit_test(15.0, painted_row + 0.0);
match hit {
MultiSectionViewHit::Header {
section: hit_section,
..
} => assert_eq!(
hit_section, s.section_idx,
"row {} paints section {} header but hit_test returns section {}",
painted_row, s.section_idx, hit_section
),
other => panic!(
"row {} paints section {} header but hit_test returns {:?}",
painted_row, s.section_idx, other
),
}
}
}
#[test]
fn aux_input_hit_returns_input_kind() {
let mut s = empty_section("sc", SectionSize::EqualShare);
s.aux = Some(SectionAux::Input(InlineInput {
id: WidgetId::new("commit"),
text: String::new(),
caret: 0,
placeholder: Some("Commit message".into()),
has_focus: false,
}));
let v = view(vec![s]);
let layout = v.layout(
Rect::new(0.0, 0.0, 30.0, 10.0),
MsvLayoutMetrics::default(),
|_| SectionMeasure {
content_size: 0.0,
aux_size: 1.0,
},
);
match layout.hit_test(15.0, 1.5) {
MultiSectionViewHit::Aux {
kind: AuxHit::Input,
section: 0,
} => {}
other => panic!("expected Aux::Input, got {:?}", other),
}
}
}