use crate::compat::{String, Vec};
use crate::core::{HorizontalAlignment, Point, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::impl_draw_bridge;
use crate::impl_widget_property_hooks;
use crate::property_names_of;
use crate::render::RenderContext;
use crate::signal::{EventSignalRef, Signal1};
use crate::widget::capability::coercion::expect_bool;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::metrics::effective_font;
use crate::widget::{BaseWidget, Draw, Widget, WidgetKind};
use super::helpers::{accent_color, surface_ink};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LayerEntry {
pub id: u64,
pub name: String,
pub visible: bool,
}
impl LayerEntry {
pub fn new(id: u64, name: impl Into<String>) -> Self {
Self { id, name: name.into(), visible: true }
}
pub fn with_visible(mut self, visible: bool) -> Self {
self.visible = visible;
self
}
}
pub const LAYER_ENTRY_SCHEMA: crate::widget::capability::RecordSchema =
crate::widget::capability::RecordSchema {
fields: &[
crate::widget::capability::RecordField::required(
"id",
crate::widget::capability::PropertyValueKind::UInt,
),
crate::widget::capability::RecordField::required(
"name",
crate::widget::capability::PropertyValueKind::String,
),
crate::widget::capability::RecordField::optional(
"visible",
crate::widget::capability::PropertyValueKind::Bool,
),
],
id_field: "id",
ids_unique: false,
ids_required: false,
};
const LAYER_CHECK_WIDTH: i32 = 18;
const LAYER_HANDLE_WIDTH: i32 = 12;
const LAYER_REMOVE_WIDTH: i32 = 16;
const LAYER_PADDING: i32 = 4;
pub struct LayerPanel {
base: BaseWidget,
entries: Vec<LayerEntry>,
selected_layer: Option<u64>,
show_visibility_checkboxes: bool,
show_reorder_handles: bool,
row_height: u32,
scroll_offset: usize,
hovered: Option<u64>,
reorder_from: Option<u64>,
renaming: Option<(u64, String)>,
pub layer_selected: Signal1<u64>,
pub visibility_toggled: Signal1<(u64, bool)>,
pub reorder_requested: Signal1<(u64, u64)>,
pub rename_requested: Signal1<(u64, String)>,
pub remove_requested: Signal1<u64>,
}
impl LayerPanel {
pub fn new(geometry: Rect) -> Self {
Self {
base: BaseWidget::new(WidgetKind::LayerPanel, geometry, "LayerPanel"),
entries: Vec::new(),
selected_layer: None,
show_visibility_checkboxes: true,
show_reorder_handles: true,
row_height: 22,
scroll_offset: 0,
hovered: None,
reorder_from: None,
renaming: None,
layer_selected: Signal1::new(),
visibility_toggled: Signal1::new(),
reorder_requested: Signal1::new(),
rename_requested: Signal1::new(),
remove_requested: Signal1::new(),
}
}
pub fn set_entries(&mut self, entries: Vec<LayerEntry>) {
self.entries = entries;
if let Some(selected) = self.selected_layer {
if !self.entries.iter().any(|entry| entry.id == selected) {
self.selected_layer = None;
}
}
let max = self.entries.len().saturating_sub(self.visible_rows());
self.scroll_offset = self.scroll_offset.min(max);
self.base.request_redraw();
}
pub fn entries(&self) -> &[LayerEntry] {
&self.entries
}
pub fn selected_layer(&self) -> Option<u64> {
self.selected_layer
}
pub fn select_layer(&mut self, id: u64) -> bool {
if !self.entries.iter().any(|entry| entry.id == id) {
return false;
}
if self.selected_layer == Some(id) {
return true;
}
self.selected_layer = Some(id);
self.scroll_to_layer(id);
self.layer_selected.emit(id);
self.base.request_redraw();
true
}
pub fn set_entry_visible(&mut self, id: u64, visible: bool) -> bool {
let Some(entry) = self.entries.iter_mut().find(|entry| entry.id == id) else {
return false;
};
if entry.visible == visible {
return true;
}
entry.visible = visible;
self.visibility_toggled.emit((id, visible));
self.base.request_redraw();
true
}
pub fn scroll_to_layer(&mut self, id: u64) -> bool {
let Some(row) = self.entries.iter().position(|entry| entry.id == id) else {
return false;
};
let visible = self.visible_rows().max(1);
if row < self.scroll_offset {
self.scroll_offset = row;
} else if row >= self.scroll_offset + visible {
self.scroll_offset = row + 1 - visible;
}
self.base.request_redraw();
true
}
pub fn visibility_checkboxes(&self) -> bool {
self.show_visibility_checkboxes
}
pub fn set_visibility_checkboxes(&mut self, show: bool) {
if self.show_visibility_checkboxes == show {
return;
}
self.show_visibility_checkboxes = show;
self.base.request_redraw();
}
pub fn reorder_handles(&self) -> bool {
self.show_reorder_handles
}
pub fn set_reorder_handles(&mut self, show: bool) {
if self.show_reorder_handles == show {
return;
}
self.show_reorder_handles = show;
self.base.request_redraw();
}
pub fn row_height(&self) -> u32 {
self.row_height
}
pub fn set_row_height(&mut self, height: u32) {
let height = height.max(12);
if self.row_height == height {
return;
}
self.row_height = height;
self.base.request_redraw();
}
fn visible_rows(&self) -> usize {
let height = self.geometry().height.max(1);
(height / self.row_height.max(1)) as usize
}
fn row_rect(&self, row: usize) -> Option<Rect> {
if row < self.scroll_offset || row >= self.scroll_offset + self.visible_rows() {
return None;
}
let rect = self.geometry();
let offset = (row - self.scroll_offset) as i32 * self.row_height as i32;
Some(Rect::new(rect.x, rect.y + offset, rect.width, self.row_height))
}
fn row_at(&self, pos: Point) -> Option<usize> {
let rect = self.geometry();
if !rect.contains_point(pos) {
return None;
}
let row =
self.scroll_offset + ((pos.y - rect.y) / self.row_height.max(1) as i32).max(0) as usize;
(row < self.entries.len()).then_some(row)
}
fn check_rect(&self, row: Rect) -> Option<Rect> {
if !self.show_visibility_checkboxes {
return None;
}
let side = 12u32.min(row.height.saturating_sub(4)).max(1);
Some(Rect::new(
row.x + LAYER_PADDING,
row.y + (row.height.saturating_sub(side) / 2) as i32,
side,
side,
))
}
fn handle_rect(&self, row: Rect) -> Option<Rect> {
if !self.show_reorder_handles {
return None;
}
Some(Rect::new(
row.x + LAYER_PADDING + LAYER_CHECK_WIDTH,
row.y,
LAYER_HANDLE_WIDTH as u32,
row.height,
))
}
fn remove_rect(&self, row: Rect) -> Rect {
Rect::new(
row.x + row.width as i32 - LAYER_REMOVE_WIDTH - LAYER_PADDING,
row.y,
LAYER_REMOVE_WIDTH as u32,
row.height,
)
}
}
impl EventHandler for LayerPanel {
fn handle_event(&mut self, event: &Event) {
self.base.handle_event(event);
if !self.base.is_enabled() {
return;
}
match event {
Event::KeyPress { key, .. } => {
if let Some((_, buffer)) = self.renaming.as_mut() {
match *key {
8 => {
buffer.pop();
self.base.request_redraw();
}
13 => {
let finished = self.renaming.take();
if let Some((id, name)) = finished {
if let Some(entry) =
self.entries.iter_mut().find(|entry| entry.id == id)
{
if !name.is_empty() {
entry.name = name.clone();
self.rename_requested.emit((id, name));
}
}
}
self.base.request_redraw();
}
27 => {
self.renaming = None;
self.base.request_redraw();
}
_ => {}
}
return;
}
match *key {
38 => self.move_selection(-1),
40 => self.move_selection(1),
46 => {
if let Some(id) = self.selected_layer {
self.remove_requested.emit(id);
}
}
_ => {}
}
}
Event::TextInput { text } | Event::ImeCommit { text } => {
if let Some((_, buffer)) = self.renaming.as_mut() {
buffer.push_str(text);
self.base.request_redraw();
}
}
Event::MouseMove { pos } => {
let hovered = self.row_at(*pos).map(|row| self.entries[row].id);
if hovered != self.hovered {
self.hovered = hovered;
self.base.request_redraw();
}
}
Event::MouseLeave { .. } => {
self.hovered = None;
}
Event::MousePress { pos, button: 1, .. } => {
let Some(row) = self.row_at(*pos) else { return };
let entry = self.entries[row].clone();
let rect = self.row_rect(row).unwrap_or_else(|| self.geometry());
if self.check_rect(rect).is_some_and(|check| check.contains_point(*pos)) {
self.set_entry_visible(entry.id, !entry.visible);
return;
}
if self.handle_rect(rect).is_some_and(|handle| handle.contains_point(*pos)) {
self.reorder_from = Some(entry.id);
return;
}
if self.remove_rect(rect).contains_point(*pos) {
self.remove_requested.emit(entry.id);
return;
}
let _ = self.select_layer(entry.id);
}
Event::MouseRelease { pos, button: 1 } => {
if let Some(from) = self.reorder_from.take() {
let to = self.row_at(*pos).map(|row| self.entries[row].id);
if let Some(to) = to {
if to != from {
self.reorder_requested.emit((from, to));
}
}
}
}
Event::MouseDoubleClick { pos, button: 1 } => {
if let Some(row) = self.row_at(*pos) {
let entry = &self.entries[row];
self.renaming = Some((entry.id, entry.name.clone()));
self.base.request_redraw();
}
}
Event::Wheel { delta, .. } if delta.y != 0 => {
let max = self.entries.len().saturating_sub(self.visible_rows().max(1));
let next = if delta.y > 0 {
(self.scroll_offset + 1).min(max)
} else {
self.scroll_offset.saturating_sub(1)
};
if next != self.scroll_offset {
self.scroll_offset = next;
self.base.request_redraw();
}
}
_ => {}
}
}
}
impl LayerPanel {
fn move_selection(&mut self, delta: isize) {
if self.entries.is_empty() {
return;
}
let current = self
.selected_layer
.and_then(|id| self.entries.iter().position(|entry| entry.id == id))
.unwrap_or(0) as isize;
let next = (current + delta).clamp(0, self.entries.len() as isize - 1) as usize;
let id = self.entries[next].id;
let _ = self.select_layer(id);
}
}
impl Draw for LayerPanel {
fn draw(&mut self, context: &mut RenderContext) {
let style = self.style();
let (surface, ink) = surface_ink(style);
let font = effective_font(style);
let accent = accent_color();
let selected_fill = ink.blend(&surface, 0.85);
let hover_fill = ink.blend(&surface, 0.93);
let rect = self.geometry();
context.fill_rect(rect, surface);
context.draw_rect(rect, ink.blend(&surface, 0.7));
let rows = self.visible_rows();
for row in self.scroll_offset..(self.scroll_offset + rows).min(self.entries.len()) {
let Some(row_rect) = self.row_rect(row) else { continue };
let entry = &self.entries[row];
if self.selected_layer == Some(entry.id) {
context.fill_rect(row_rect, selected_fill);
} else if self.hovered == Some(entry.id) {
context.fill_rect(row_rect, hover_fill);
}
let mut label_x = row_rect.x + LAYER_PADDING;
if let Some(check) = self.check_rect(row_rect) {
context.draw_rect(check, ink.blend(&surface, 0.5));
if entry.visible {
context.fill_rect(
Rect::new(
check.x + 2,
check.y + 2,
check.width.saturating_sub(4),
check.height.saturating_sub(4),
),
accent,
);
}
label_x += LAYER_CHECK_WIDTH;
}
if let Some(handle) = self.handle_rect(row_rect) {
let mid = handle.y + handle.height as i32 / 2;
for offset in [-2i32, 2] {
context.draw_line(
Point::new(handle.x + 2, mid + offset),
Point::new(handle.x + handle.width as i32 - 2, mid + offset),
ink.with_alpha(90),
);
}
label_x += LAYER_HANDLE_WIDTH;
}
let remove = self.remove_rect(row_rect);
let label_width = (remove.x - 4 - label_x).max(0) as u32;
let label = match &self.renaming {
Some((id, buffer)) if *id == entry.id => buffer.as_str(),
_ => entry.name.as_str(),
};
let line = context
.text_line(Rect::new(label_x, row_rect.y, label_width, row_rect.height), font);
context.draw_text_fitted_cow(
Rect::new(label_x, line.y, label_width, line.height),
label,
font,
if entry.visible { ink } else { ink.with_alpha(110) },
HorizontalAlignment::Left,
);
let cross_ink = ink.with_alpha(150);
let cx = remove.x + remove.width as i32 / 2;
let cy = remove.y + remove.height as i32 / 2;
context.draw_line_stroke(
Point::new(cx - 3, cy - 3),
Point::new(cx + 3, cy + 3),
cross_ink,
1,
);
context.draw_line_stroke(
Point::new(cx + 3, cy - 3),
Point::new(cx - 3, cy + 3),
cross_ink,
1,
);
}
}
}
impl Widget for LayerPanel {
fn base(&self) -> &BaseWidget {
&self.base
}
fn set_state_theme_hook(&mut self) {
crate::style::reapply_active_theme_state(self);
}
fn base_mut(&mut self) -> &mut BaseWidget {
&mut self.base
}
fn size_hint(&self) -> Size {
Size::new(180, (self.entries.len() as u32 * self.row_height).max(self.row_height))
}
impl_draw_bridge!();
impl_widget_property_hooks!();
fn event_signal_dyn(&self, name: &str) -> Option<EventSignalRef> {
match name {
"layer_selected" => {
Some(EventSignalRef::mapped("layer_selected", &self.layer_selected, |value| {
CapabilityValue::UInt(*value)
}))
}
"visibility_toggled" => Some(EventSignalRef::mapped(
"visibility_toggled",
&self.visibility_toggled,
|value| {
CapabilityValue::Tuple(Vec::from([
CapabilityValue::UInt(value.0),
CapabilityValue::Bool(value.1),
]))
},
)),
"reorder_requested" => Some(EventSignalRef::mapped(
"reorder_requested",
&self.reorder_requested,
|value| {
CapabilityValue::Tuple(Vec::from([
CapabilityValue::UInt(value.0),
CapabilityValue::UInt(value.1),
]))
},
)),
"rename_requested" => {
Some(EventSignalRef::mapped("rename_requested", &self.rename_requested, |value| {
CapabilityValue::Tuple(Vec::from([
CapabilityValue::UInt(value.0),
CapabilityValue::String(value.1.clone()),
]))
}))
}
"remove_requested" => {
Some(EventSignalRef::mapped("remove_requested", &self.remove_requested, |value| {
CapabilityValue::UInt(*value)
}))
}
_ => None,
}
}
}
fn layer_entry_record(entry: &LayerEntry) -> crate::widget::capability::RecordValue {
crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::UInt(entry.id))
.with("name", CapabilityValue::String(entry.name.clone()))
.with("visible", CapabilityValue::Bool(entry.visible))
}
fn layer_entry_from_record(
record: &crate::widget::capability::RecordValue,
) -> Result<LayerEntry, CapabilityAccessError> {
let id = match record.get("id") {
Some(CapabilityValue::UInt(id)) => *id,
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let name = match record.get("name") {
Some(CapabilityValue::String(name)) => name.clone(),
_ => return Err(CapabilityAccessError::TypeMismatch),
};
let visible = match record.get("visible") {
None | Some(CapabilityValue::Null) => true,
Some(CapabilityValue::Bool(visible)) => *visible,
Some(_) => return Err(CapabilityAccessError::TypeMismatch),
};
Ok(LayerEntry { id, name, visible })
}
impl WidgetProperties for LayerPanel {
fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
match name {
"entries" => {
Ok(CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&LAYER_ENTRY_SCHEMA,
self.entries.iter().map(layer_entry_record).collect(),
)))
}
"entry_count" => Ok(CapabilityValue::UInt(self.entries.len() as u64)),
"selected_layer" => Ok(match self.selected_layer {
Some(id) => CapabilityValue::UInt(id),
None => CapabilityValue::Null,
}),
"show_visibility_checkboxes" => {
Ok(CapabilityValue::Bool(self.show_visibility_checkboxes))
}
"show_reorder_handles" => Ok(CapabilityValue::Bool(self.show_reorder_handles)),
"row_height" => Ok(CapabilityValue::UInt(u64::from(self.row_height))),
_ => base_property_get(self, name),
}
}
fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
match name {
"entries" => {
let CapabilityValue::Records(list) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
list.validate_against(&LAYER_ENTRY_SCHEMA).map_err(|violation| {
log::warn!("layer_panel.entries refused: {violation}");
CapabilityAccessError::ConstraintViolation {
property: crate::compat::String::from("entries"),
reason: crate::compat::format!("{violation}"),
}
})?;
let entries = list.items().iter().map(layer_entry_from_record).collect::<Result<
Vec<_>,
CapabilityAccessError,
>>(
)?;
self.set_entries(entries);
Ok(())
}
"entry_count" | "selected_layer" => Err(CapabilityAccessError::ReadOnlyProperty),
"show_visibility_checkboxes" => {
self.set_visibility_checkboxes(expect_bool(value)?);
Ok(())
}
"show_reorder_handles" => {
self.set_reorder_handles(expect_bool(value)?);
Ok(())
}
"row_height" => {
let CapabilityValue::UInt(height) = value else {
return Err(CapabilityAccessError::TypeMismatch);
};
self.set_row_height(u32::try_from(height).unwrap_or(u32::MAX));
Ok(())
}
_ => base_property_set(self, name, value),
}
}
fn property_names(&self) -> &'static [&'static str] {
property_names_of![
"entries",
"entry_count",
"selected_layer",
"show_visibility_checkboxes",
"show_reorder_handles",
"row_height",
BASE_PROPERTY_NAMES
]
}
fn command(&mut self, name: &str) -> Result<(), CapabilityAccessError> {
match name {
"select_layer" | "set_entry_visible" | "scroll_to_layer" => {
Err(CapabilityAccessError::OutOfRange)
}
_ => self.default_command(name),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Arc, Mutex};
fn panel() -> LayerPanel {
let mut panel = LayerPanel::new(Rect::new(0, 0, 200, 100));
panel.set_entries(vec![
LayerEntry::new(1, "background"),
LayerEntry::new(2, "ink"),
LayerEntry::new(3, "notes").with_visible(false),
]);
panel
}
#[test]
fn replacing_the_entries_drops_a_selection_that_no_longer_exists() {
let mut panel = panel();
assert!(panel.select_layer(2));
assert_eq!(panel.selected_layer(), Some(2));
panel.set_entries(vec![LayerEntry::new(1, "background")]);
assert_eq!(panel.selected_layer(), None, "a stale id must not stay selected");
}
#[test]
fn the_checkbox_toggles_visibility_without_moving_the_selection() {
let mut panel = panel();
assert!(panel.select_layer(1));
let toggled = Arc::new(Mutex::new(Vec::<(u64, bool)>::new()));
let sink = Arc::clone(&toggled);
panel.visibility_toggled.connect(move |value| {
if let Ok(mut guard) = sink.lock() {
guard.push(*value);
}
});
panel.handle_event(&Event::mouse_press(10, 33, 1));
assert_eq!(*toggled.lock().expect("toggled lock"), vec![(2, false)]);
assert_eq!(panel.selected_layer(), Some(1), "toggling is not selecting");
assert!(!panel.entries()[1].visible);
}
#[test]
fn a_handle_drag_requests_a_reorder() {
let mut panel = panel();
let reordered = Arc::new(Mutex::new(Vec::<(u64, u64)>::new()));
let sink = Arc::clone(&reordered);
panel.reorder_requested.connect(move |value| {
if let Ok(mut guard) = sink.lock() {
guard.push(*value);
}
});
panel.handle_event(&Event::mouse_press(24, 11, 1));
panel.handle_event(&Event::mouse_release(24, 33, 1));
assert_eq!(*reordered.lock().expect("reordered lock"), vec![(1, 2)]);
}
#[test]
fn a_double_click_renames_through_the_signal() {
let mut panel = panel();
let renamed = Arc::new(Mutex::new(Vec::<(u64, String)>::new()));
let sink = Arc::clone(&renamed);
panel.rename_requested.connect(move |value| {
if let Ok(mut guard) = sink.lock() {
guard.push((*value).clone());
}
});
panel.handle_event(&Event::mouse_double_click(100, 11, 1));
for _ in 0.."background".len() {
panel.handle_event(&Event::key_press(8, 0));
}
panel.handle_event(&Event::text_input("base"));
panel.handle_event(&Event::key_press(13, 0));
let expected = vec![(1u64, "base".to_string())];
assert_eq!(*renamed.lock().expect("renamed lock"), expected);
assert_eq!(panel.entries()[0].name, "base");
}
#[test]
fn entries_round_trip_through_the_property_route() {
let mut panel = panel();
let read = panel.get("entries").expect("entries is readable");
let CapabilityValue::Records(list) = read.clone() else {
panic!("entries must be a record list");
};
assert_eq!(list.items().len(), 3);
assert_eq!(list.items()[0].get("id"), Some(&CapabilityValue::UInt(1)));
assert_eq!(
list.items()[0].get("name"),
Some(&CapabilityValue::String("background".to_string()))
);
assert_eq!(list.items()[2].get("visible"), Some(&CapabilityValue::Bool(false)));
panel.set("entries", read).expect("identity write");
assert_eq!(panel.entries().len(), 3);
let refused = panel.set(
"entries",
CapabilityValue::Records(crate::widget::capability::RecordList::from_items(
&LAYER_ENTRY_SCHEMA,
vec![crate::widget::capability::RecordValue::new()
.with("id", CapabilityValue::UInt(9))],
)),
);
assert!(matches!(refused, Err(CapabilityAccessError::ConstraintViolation { .. })));
assert_eq!(panel.entries().len(), 3);
}
#[test]
fn set_entries_reports_a_missing_payload() {
let mut panel = panel();
assert_eq!(panel.command("set_entries"), Err(CapabilityAccessError::OutOfRange));
}
}