forked from jess/Acord
1
0
Fork 0
Acord/viewport/src/browser/ui.rs

539 lines
18 KiB
Rust

use iced_wgpu::core::text::{Span as TextSpan, Wrapping};
use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Pixels, Point, Size, Theme};
use iced_widget::text::Rich;
use iced_widget::{
button, column, container, mouse_area, opaque, responsive, row, scrollable, span, stack, text,
text_input, Space,
};
use crate::palette;
use crate::syntax::{highlight_color, highlight_font};
use super::model::{BrowserItem, BrowserItemKind};
use super::preview::PreviewLine;
use super::state::{BrowserMessage, BrowserState, ContextMenu};
const TARGET_CARD_W: f32 = 280.0;
const MIN_CARD_W: f32 = 180.0;
const GAP: f32 = 16.0;
const OUTER_PAD: f32 = 16.0;
const CARD_PAD: f32 = 10.0;
const CARD_ASPECT: f32 = 0.72;
pub fn view(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let body_inner: Element<_, _, _> = if state.items.is_empty() {
empty_state()
} else {
responsive(|size| scrollable(grid(state, size)).height(Length::Fill).into()).into()
};
// Captures right-clicks between cards plus drag motion/release that
// happens off-card. Cards have their own on_right_press / on_press, so
// this body-level mouse_area only sees the gaps for those.
let body: Element<_, _, _> = mouse_area(body_inner)
.on_right_press(BrowserMessage::ShowEmptyContextMenu)
.on_move(BrowserMessage::DragMove)
.on_release(BrowserMessage::DragEnd)
.into();
let main: Element<_, _, _> = column![
breadcrumb(state),
rule(p.surface1),
body,
]
.height(Length::Fill)
.into();
let layered: Element<_, _, _> = match state.context_menu.as_ref() {
Some(menu) => stack![main, context_menu_overlay(state, menu)].into(),
None => main,
};
container(layered)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.base)),
border: Border::default(),
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn breadcrumb(state: &BrowserState) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let segments = state.path_segments();
let last_idx = segments.len().saturating_sub(1);
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
for (i, (name, path)) in segments.into_iter().enumerate() {
if i > 0 {
row_items.push(
text(">").size(11.0).color(p.overlay0).into()
);
}
let is_last = i == last_idx;
let label = text(name).size(12.0).color(if is_last { p.text } else { p.subtext0 });
let btn = button(label)
.padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 })
.style(move |_t: &Theme, _s| button::Style {
background: None,
text_color: if is_last { p.text } else { p.subtext0 },
border: Border::default(),
shadow: Default::default(),
snap: false,
})
.on_press(BrowserMessage::NavigateTo(path));
row_items.push(btn.into());
}
container(row(row_items).spacing(2.0))
.padding(Padding { top: 8.0, right: 16.0, bottom: 8.0, left: 16.0 })
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.mantle)),
border: Border::default(),
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.width(Length::Fill)
.into()
}
fn rule(color: Color) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
container(text(""))
.width(Length::Fill)
.height(Length::Fixed(1.0))
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(color)),
border: Border::default(),
text_color: None,
shadow: Default::default(),
snap: false,
})
.into()
}
fn empty_state() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
container(
column![
text("No documents").size(16.0).color(p.subtext0),
text("Create a new note or add files to this folder").size(12.0).color(p.overlay0),
]
.spacing(8.0)
)
.width(Length::Fill)
.height(Length::Fill)
.padding(Padding { top: 100.0, right: 0.0, bottom: 0.0, left: 0.0 })
.center_x(Length::Fill)
.into()
}
/// picks the column count whose card-width sits closest to the scale-adjusted target.
fn columns_for_width(avail_w: f32, scale: f32) -> usize {
let target = TARGET_CARD_W * scale;
let min_w = MIN_CARD_W * scale;
let inner = (avail_w - 2.0 * OUTER_PAD).max(0.0);
if inner < min_w {
return 1;
}
let mut best = 1usize;
let mut best_diff = f32::MAX;
for n in 1..=8 {
let nf = n as f32;
let card_w = (inner - (nf - 1.0) * GAP * scale) / nf;
if card_w < min_w {
break;
}
let diff = (card_w - target).abs();
if diff < best_diff {
best_diff = diff;
best = n;
}
}
best
}
/// lays out items as a fill-the-width grid of fixed-aspect cards.
fn grid(state: &BrowserState, size: Size) -> Element<'_, BrowserMessage, Theme, iced_wgpu::Renderer> {
let scale = state.scale;
let cols = columns_for_width(size.width, scale);
let inner = (size.width - 2.0 * OUTER_PAD).max(0.0);
let card_w = ((inner - (cols as f32 - 1.0) * GAP * scale) / cols as f32).max(MIN_CARD_W * scale);
let card_h = card_w * CARD_ASPECT;
let mut rows: Vec<Element<_, _, _>> = Vec::new();
for chunk in state.items.chunks(cols) {
let mut row_items: Vec<Element<_, _, _>> = Vec::new();
for item in chunk {
row_items.push(card(item, state, scale, card_w, card_h));
}
while row_items.len() < cols {
row_items.push(
Space::new()
.width(Length::Fixed(card_w))
.height(Length::Fixed(card_h))
.into()
);
}
rows.push(
row(row_items)
.spacing(GAP * scale)
.into()
);
}
container(
column(rows)
.spacing(GAP * scale)
.width(Length::Fill)
)
.padding(OUTER_PAD)
.width(Length::Fill)
.into()
}
/// stacks a kind-specific preview above a title strip inside one click target.
fn card<'a>(
item: &'a BrowserItem,
state: &'a BrowserState,
scale: f32,
card_w: f32,
card_h: f32,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let selected = state.is_selected(item);
let renaming = state.is_renaming(item);
let dragging = state.is_dragging(item);
let drop_target = state.is_drop_target(item);
let title_size = 12.0 * scale;
let title_h = title_size * 1.4 + 4.0;
let preview_h = (card_h - title_h - CARD_PAD * 2.0 - 6.0 * scale).max(0.0);
let preview: Element<_, _, _> = match item.kind {
BrowserItemKind::Folder => folder_preview(&item.preview, scale, preview_h),
BrowserItemKind::File => file_preview(&item.preview_lines, scale, preview_h),
};
let title: Element<_, _, _> = if renaming {
text_input("Name", &state.rename_text)
.on_input(BrowserMessage::UpdateRename)
.on_submit(BrowserMessage::CommitRename)
.size(title_size)
.padding(Padding { top: 2.0, right: 4.0, bottom: 2.0, left: 4.0 })
.into()
} else {
container(
text(item.name.clone())
.size(title_size)
.color(p.text)
.wrapping(Wrapping::None),
)
.width(Length::Fill)
.height(Length::Fixed(title_h))
.clip(true)
.into()
};
let content = column![preview, title].spacing(6.0 * scale);
let item_path = item.path.clone();
let is_file = item.kind == BrowserItemKind::File;
let body = container(content)
.width(Length::Fixed(card_w))
.height(Length::Fixed(card_h))
.padding(CARD_PAD)
.clip(true)
.style(move |_t: &Theme| {
let (border_color, border_width, bg) = if drop_target {
(p.green, 2.0, p.surface1)
} else if selected {
(p.blue, 2.0, p.surface1)
} else {
(p.surface2, 1.0, p.surface0)
};
let bg = if dragging {
Color { a: 0.5, ..bg }
} else {
bg
};
container::Style {
background: Some(Background::Color(bg)),
border: Border {
color: border_color,
width: border_width,
radius: (8.0 * scale).into(),
},
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
}
});
let open_msg = match item.kind {
BrowserItemKind::Folder => BrowserMessage::NavigateTo(item_path.clone()),
BrowserItemKind::File => BrowserMessage::Open(item_path.clone()),
};
let is_folder = !is_file;
mouse_area(body)
.on_press(BrowserMessage::Select(item_path.clone()))
.on_double_click(open_msg)
.on_right_press(BrowserMessage::ShowContextMenu {
path: item_path.clone(),
is_file,
})
.on_enter(BrowserMessage::CardHoverEnter { path: item_path.clone(), is_folder })
.on_exit(BrowserMessage::CardHoverExit(item_path))
.into()
}
/// renders a folder icon and item-count summary inside the card's preview slot.
fn folder_preview(
summary: &str,
scale: f32,
preview_h: f32,
) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
container(
row![
text("\u{1F4C1}").size(24.0 * scale).color(p.blue),
text(summary.to_string()).size(10.0 * scale).color(p.subtext0),
]
.spacing(8.0 * scale)
)
.width(Length::Fill)
.height(Length::Fixed(preview_h))
.padding(8.0 * scale)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.mantle)),
border: Border { color: Color::TRANSPARENT, width: 0.0, radius: (4.0 * scale).into() },
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.into()
}
/// renders pre-highlighted preview lines as a clipped column of rich-text.
fn file_preview<'a>(
lines: &'a [PreviewLine],
scale: f32,
preview_h: f32,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let body_size = 9.0 * scale;
let line_spacing = 2.0 * scale;
if lines.is_empty() {
return container(text("(empty)").size(body_size).color(p.overlay0))
.width(Length::Fill)
.height(Length::Fixed(preview_h))
.padding(8.0 * scale)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.mantle)),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: (4.0 * scale).into(),
},
text_color: Some(p.subtext0),
shadow: Default::default(),
snap: false,
})
.into();
}
let mut col_items: Vec<Element<_, _, _>> = Vec::new();
for line in lines {
let size = match line.heading {
Some(1) => body_size * 1.5,
Some(2) => body_size * 1.3,
Some(3) => body_size * 1.15,
_ => body_size,
};
col_items.push(preview_line(line, size, p.subtext0));
}
let inner = column(col_items).spacing(line_spacing);
container(inner)
.width(Length::Fill)
.height(Length::Fixed(preview_h))
.padding(8.0 * scale)
.clip(true)
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.mantle)),
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: (4.0 * scale).into(),
},
text_color: Some(p.subtext0),
shadow: Default::default(),
snap: false,
})
.into()
}
/// turns one preview line's syntax spans into a rich-text element at the given size.
fn preview_line<'a>(
line: &'a PreviewLine,
size: f32,
fallback: Color,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
if line.text.is_empty() {
return Space::new().width(Length::Shrink).height(Length::Fixed(size * 0.6)).into();
}
let mut spans: Vec<TextSpan<'a, ()>> = Vec::new();
let mut cursor = 0usize;
for (range, kind) in &line.spans {
if range.start > cursor {
spans.push(plain_span(&line.text[cursor..range.start], fallback));
}
let slice = &line.text[range.start..range.end];
let color = highlight_color(*kind);
let mut s = span(slice).color(color);
if let Some(font) = highlight_font(*kind) {
s = s.font(font);
}
spans.push(s);
cursor = range.end;
}
if cursor < line.text.len() {
spans.push(plain_span(&line.text[cursor..], fallback));
}
Rich::with_spans(spans).size(Pixels(size)).into()
}
fn plain_span<'a>(text: &'a str, color: Color) -> TextSpan<'a, ()> {
span(text).color(color)
}
/// stacks a click-out catcher behind a positioned menu pinned at the right-click anchor.
fn context_menu_overlay<'a>(
state: &'a BrowserState,
menu: &'a ContextMenu,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
let dismiss = mouse_area(Space::new().width(Length::Fill).height(Length::Fill))
.on_press(BrowserMessage::HideContextMenu)
.on_right_press(BrowserMessage::HideContextMenu);
let full = state.context_acts_on_selection();
let positioned = positioned_menu(menu.anchor, menu_column(state, full));
stack![dismiss, positioned].into()
}
/// places the menu column at an absolute anchor by padding from the top-left.
fn positioned_menu<'a>(
anchor: Point,
inner: Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer>,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
let menu = opaque(inner);
column![
Space::new().width(Length::Shrink).height(Length::Fixed(anchor.y.max(0.0))),
row![
Space::new().width(Length::Fixed(anchor.x.max(0.0))).height(Length::Shrink),
menu,
],
]
.into()
}
/// renders the unified menu used by both the context menu and the menu bar.
/// `full` decides whether to show selection-dependent items beyond New Folder.
fn menu_column<'a>(
state: &'a BrowserState,
full: bool,
) -> Element<'a, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
let mut items: Vec<Element<_, _, _>> = Vec::new();
if full {
let single = state.single_selected();
if let Some(path) = &single {
let label = if path.is_dir() { "Open Folder" } else { "Open" };
items.push(menu_item(label, BrowserMessage::ContextOpen));
items.push(menu_item("Rename", BrowserMessage::ContextRename));
}
items.push(menu_item("Duplicate", BrowserMessage::ContextDuplicate));
items.push(menu_separator());
items.push(menu_item("Delete", BrowserMessage::ContextTrash));
items.push(menu_separator());
}
items.push(menu_item("New Folder", BrowserMessage::NewFolder));
if full {
items.push(menu_item("New Folder with Selection", BrowserMessage::NewFolderWithSelection));
}
container(column(items).spacing(0.0))
.width(Length::Fixed(220.0))
.padding(Padding { top: 4.0, right: 0.0, bottom: 4.0, left: 0.0 })
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.surface1)),
border: Border {
color: p.overlay1,
width: 1.0,
radius: 6.0.into(),
},
text_color: Some(p.text),
shadow: Default::default(),
snap: false,
})
.into()
}
/// one clickable row inside a menu.
fn menu_item(
label: &'static str,
msg: BrowserMessage,
) -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
button(text(label).size(12.0).color(p.text))
.padding(Padding { top: 6.0, right: 12.0, bottom: 6.0, left: 12.0 })
.width(Length::Fill)
.style(move |_t: &Theme, status| {
let bg = match status {
button::Status::Hovered => Some(Background::Color(p.surface2)),
_ => None,
};
button::Style {
background: bg,
text_color: p.text,
border: Border::default(),
shadow: Default::default(),
snap: false,
}
})
.on_press(msg)
.into()
}
/// a thin separator line between menu sections.
fn menu_separator() -> Element<'static, BrowserMessage, Theme, iced_wgpu::Renderer> {
let p = palette::current();
container(Space::new().width(Length::Fill).height(Length::Fixed(1.0)))
.padding(Padding { top: 4.0, right: 6.0, bottom: 4.0, left: 6.0 })
.style(move |_t: &Theme| container::Style {
background: Some(Background::Color(p.overlay0)),
border: Border::default(),
text_color: None,
shadow: Default::default(),
snap: false,
})
.width(Length::Fill)
.into()
}