use eframe::egui::{
self, pos2, vec2, Align2, Color32, CornerRadius, Id, Rect, RichText, Sense, Stroke, UiBuilder,
};
use crate::i18n::{self, Key, Lang};
use crate::model::{Combo, Node, Workspace, PALETTE};
use crate::shell::IconCache;
use crate::ui::{style, Action, DropUi};
use crate::app::App;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct DropTarget {
pub parent: u64,
pub index: usize,
pub row: u64,
pub into: bool,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum RenameHost {
#[default]
Projects,
Tree,
}
impl RenameHost {
fn tag(self) -> u8 {
match self {
Self::Projects => 0,
Self::Tree => 1,
}
}
}
fn rename_id(host: RenameHost, id: u64) -> Id {
Id::new(("fd-rename", host.tag(), id))
}
#[derive(Default)]
pub struct State {
pub renaming: Option<u64>,
pub rename_host: RenameHost,
pub rename_buf: String,
pub drag: Option<u64>,
pub drag_armed: bool,
pub drop: Option<DropTarget>,
pub proj_drag: Option<usize>,
pub proj_drop: Option<usize>,
}
impl State {
fn clear_drag(&mut self) {
self.drag = None;
self.drop = None;
self.drag_armed = false;
}
fn begin_rename(&mut self, host: RenameHost, id: u64, name: impl Into<String>) {
self.renaming = Some(id);
self.rename_host = host;
self.rename_buf = name.into();
}
fn is_renaming(&self, host: RenameHost, id: u64) -> bool {
self.renaming == Some(id) && self.rename_host == host
}
}
fn start_rename(ui: &egui::Ui, st: &mut State, host: RenameHost, id: u64, name: &str) {
st.begin_rename(host, id, name);
ui.memory_mut(|mem| mem.request_focus(rename_id(host, id)));
}
pub fn draw_projects(app: &mut App, ui: &mut egui::Ui, actions: &mut Vec<Action>) {
let App {
ws,
sidebar_state: st,
..
} = app;
let lang = ws.settings.lang;
let pal = style::palette(style::is_dark(ui.ctx(), ws.settings.theme), ws.settings.accent);
let m = style::metrics(ws.settings.density);
ui.spacing_mut().item_spacing.y = 2.0;
ui.horizontal(|ui| {
ui.label(
RichText::new(i18n::t(lang, Key::Projects))
.size(11.5)
.color(pal.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if style::icon_button(ui, "+", i18n::t(lang, Key::NewProjectTip), &pal).clicked() {
actions.push(Action::NewProject);
}
});
});
ui.add_space(2.0);
let list_height = (ui.available_height() - 30.0).max(60.0);
egui::ScrollArea::vertical()
.id_salt("fd-projects")
.max_height(list_height)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
let total = ws.projects.len();
for (index, project) in ws.projects.iter().enumerate() {
project_row(ws, project, index, total, ui, st, lang, &pal, &m, actions);
}
});
let width = ui.available_width();
if ui
.add_sized(
[width, 22.0],
egui::Button::new(i18n::t(lang, Key::NewProjectButton)),
)
.on_hover_text(i18n::t(lang, Key::NewProjectTip))
.clicked()
{
actions.push(Action::NewProject);
}
}
pub fn draw_tree(app: &mut App, ui: &mut egui::Ui, actions: &mut Vec<Action>, drop: &mut DropUi) {
let App {
ws,
sidebar_state: st,
icons,
..
} = app;
let lang = ws.settings.lang;
let pal = style::palette(style::is_dark(ui.ctx(), ws.settings.theme), ws.settings.accent);
let m = style::metrics(ws.settings.density);
ui.spacing_mut().item_spacing.y = 2.0;
st.drop = None;
let project = ws.active_project().clone();
ui.horizontal(|ui| {
let project_color =
egui::Color32::from_rgb(project.color[0], project.color[1], project.color[2]);
ui.label(RichText::new(&project.name).size(11.5).color(project_color));
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if style::text_button(
ui,
i18n::t(lang, Key::CollapseAll),
i18n::t(lang, Key::CollapseAll),
&pal,
button_width(ui, i18n::t(lang, Key::CollapseAll)),
)
.clicked()
{
actions.push(Action::SetExpandedAll(false));
}
if style::text_button(
ui,
i18n::t(lang, Key::ExpandAll),
i18n::t(lang, Key::ExpandAll),
&pal,
button_width(ui, i18n::t(lang, Key::ExpandAll)),
)
.clicked()
{
actions.push(Action::SetExpandedAll(true));
}
});
});
ui.add_space(2.0);
ui.separator();
let tree_height = (ui.available_height() - 60.0).max(80.0);
egui::ScrollArea::vertical()
.id_salt("fd-tree")
.max_height(tree_height)
.auto_shrink([false, false])
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 1.0;
draw_combo_row(ws, &project, 0, 0, 0, false, ui, st, lang, &pal, &m, actions, drop);
if project.expanded {
draw_children(
ws,
&project.children,
project.id,
1,
ui,
st,
icons,
lang,
&pal,
&m,
actions,
drop,
);
}
});
ui.add_space(4.0);
let width = ui.available_width();
let target_combo = match ws.node(ws.selected) {
Some(node) => match node.as_combo() {
Some(combo) => combo.id,
None => ws.parent_of(node.id()).unwrap_or(ws.active_project),
},
None => ws.active_project,
};
if ui
.add_sized(
[width, 24.0],
egui::Button::new(i18n::t(lang, Key::AddFolderEllipsis)),
)
.on_hover_text(i18n::t(lang, Key::OpenFolderPickTip))
.clicked()
{
actions.push(Action::AddFolderFromPicker { parent: target_combo });
}
if ui
.add_sized(
[width, 24.0],
egui::Button::new(i18n::t(lang, Key::AddComboEllipsis)),
)
.on_hover_text(i18n::t(lang, Key::NewComboTip))
.clicked()
{
actions.push(Action::AddCombo {
parent: target_combo,
});
}
}
fn draw_disclosure(ui: &egui::Ui, rect: Rect, expanded: bool, color: Color32) {
let c = rect.center();
let s = 3.6;
let points = if expanded {
vec![
pos2(c.x - s, c.y - s * 0.7),
pos2(c.x + s, c.y - s * 0.7),
pos2(c.x, c.y + s * 0.9),
]
} else {
vec![
pos2(c.x - s * 0.7, c.y - s),
pos2(c.x - s * 0.7, c.y + s),
pos2(c.x + s * 0.9, c.y),
]
};
ui.painter()
.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
}
fn button_width(ui: &egui::Ui, text: &str) -> f32 {
ui.painter()
.layout_no_wrap(text.to_string(), style::small_font(), egui::Color32::WHITE)
.size()
.x
+ 14.0
}
#[allow(clippy::too_many_arguments)]
fn project_row(
ws: &Workspace,
project: &Combo,
index: usize,
total: usize,
ui: &mut egui::Ui,
st: &mut State,
lang: Lang,
pal: &style::Palette,
m: &style::Metrics,
actions: &mut Vec<Action>,
) {
let (rect, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), Sense::click_and_drag());
let is_active = ws.active_project == project.id;
let selected = ws.selected == project.id;
if selected {
ui.painter()
.rect_filled(rect, CornerRadius::same(5), pal.accent_soft);
} else if resp.hovered() || st.proj_drag == Some(index) {
ui.painter()
.rect_filled(rect, CornerRadius::same(5), pal.hover);
}
if let (Some(from), Some(to)) = (st.proj_drag, st.proj_drop) {
if from != to && to == index {
let y = if to > from { rect.bottom() } else { rect.top() };
ui.painter().line_segment(
[pos2(rect.left() + 3.0, y), pos2(rect.right() - 3.0, y)],
Stroke::new(2.0, pal.accent),
);
}
}
ui.painter().circle_filled(
pos2(rect.left() + 10.0, rect.center().y),
4.5,
Color32::from_rgb(project.color[0], project.color[1], project.color[2]),
);
if st.is_renaming(RenameHost::Projects, project.id) {
let text_rect = Rect::from_min_max(
pos2(rect.left() + 19.0, rect.top() + 1.0),
pos2(rect.right() - 24.0, rect.bottom() - 1.0),
);
edit_name(ui, st, RenameHost::Projects, project.id, text_rect, lang, actions);
} else {
let clip = Rect::from_min_max(
pos2(rect.left() + 19.0, rect.top()),
pos2(rect.right() - 24.0, rect.bottom()),
);
ui.painter().with_clip_rect(clip).text(
pos2(rect.left() + 19.0, rect.center().y),
Align2::LEFT_CENTER,
&project.name,
style::body_font(),
if is_active { pal.text } else { pal.text.gamma_multiply(0.85) },
);
}
ui.painter().text(
pos2(rect.right() - 6.0, rect.center().y),
Align2::RIGHT_CENTER,
ws.folder_count(project.id).to_string(),
style::small_font(),
pal.muted,
);
if resp.clicked() {
actions.push(Action::SwitchProject(project.id));
}
if resp.drag_started() {
st.proj_drag = Some(index);
st.proj_drop = None;
}
if st.proj_drag == Some(index) && resp.dragged() {
if let Some(pointer) = resp.hover_pos() {
let pitch = m.tree_row + 1.0;
let delta = ((pointer.y - resp.rect.center().y) / pitch).round() as isize;
let target = (index as isize + delta).clamp(0, total as isize - 1) as usize;
st.proj_drop = Some(target);
}
}
if resp.drag_stopped() {
if let (Some(from), Some(to)) = (st.proj_drag.take(), st.proj_drop.take()) {
if from != to {
actions.push(Action::MoveProject { from, to });
}
}
}
let id = project.id;
let name = project.name.clone();
let mut remove = false;
resp.context_menu(|ui| {
if ui.button(i18n::t(lang, Key::Rename)).clicked() {
start_rename(ui, st, RenameHost::Projects, id, &name);
ui.close();
}
if ui.button(i18n::t(lang, Key::DuplicateCombo)).clicked() {
actions.push(Action::DuplicateNode(id));
ui.close();
}
ui.separator();
if ui
.add_enabled(
ws.projects.len() > 1,
egui::Button::new(i18n::t(lang, Key::Delete)),
)
.clicked()
{
remove = true;
ui.close();
}
});
if remove {
actions.push(Action::RemoveNode(id));
}
}
#[allow(clippy::too_many_arguments)]
fn draw_children(
ws: &Workspace,
children: &[Node],
parent: u64,
depth: usize,
ui: &mut egui::Ui,
st: &mut State,
icons: &IconCache,
lang: Lang,
pal: &style::Palette,
m: &style::Metrics,
actions: &mut Vec<Action>,
drop: &mut DropUi,
) {
for (index, child) in children.iter().enumerate() {
match child {
Node::Combo(combo) => {
draw_combo_row(
ws, combo, parent, index, depth, true, ui, st, lang, pal, m, actions, drop,
);
if combo.expanded {
draw_children(
ws,
&combo.children,
combo.id,
depth + 1,
ui,
st,
icons,
lang,
pal,
m,
actions,
drop,
);
}
}
Node::Folder(folder) => draw_folder_row(
ws,
&folder.title(),
&folder.path,
folder.id,
parent,
index,
depth,
ui,
st,
icons,
lang,
pal,
m,
actions,
drop,
),
}
}
}
#[allow(clippy::too_many_arguments)]
fn draw_combo_row(
ws: &Workspace,
combo: &Combo,
parent: u64,
index: usize,
depth: usize,
draggable: bool,
ui: &mut egui::Ui,
st: &mut State,
lang: Lang,
pal: &style::Palette,
m: &style::Metrics,
actions: &mut Vec<Action>,
drop: &mut DropUi,
) {
let sense = if draggable {
Sense::click_and_drag()
} else {
Sense::click()
};
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), sense);
let indent = 4.0 + depth as f32 * 14.0;
let selected = ws.selected == combo.id;
paint_row_bg(ui, rect, resp.hovered() || st.drag == Some(combo.id), selected, pal);
paint_drop_hint(ui, rect, combo.id, index, st, pal);
let arrow_rect = Rect::from_min_size(
pos2(rect.left() + indent, rect.top()),
vec2(14.0, rect.height()),
);
draw_disclosure(ui, arrow_rect, combo.expanded, pal.muted);
let arrow = ui.interact(arrow_rect, Id::new(("fd-arrow", combo.id)), Sense::click());
if arrow.clicked() {
actions.push(Action::ToggleExpanded(combo.id));
}
ui.painter().circle_filled(
pos2(rect.left() + indent + 22.0, rect.center().y),
4.0,
Color32::from_rgb(combo.color[0], combo.color[1], combo.color[2]),
);
let text_left = rect.left() + indent + 31.0;
let text_clip =
Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 30.0, rect.bottom()));
if st.is_renaming(RenameHost::Tree, combo.id) {
edit_name(
ui,
st,
RenameHost::Tree,
combo.id,
text_clip.shrink2(vec2(0.0, 1.0)),
lang,
actions,
);
} else {
ui.painter().with_clip_rect(text_clip).text(
pos2(text_left, rect.center().y),
Align2::LEFT_CENTER,
&combo.name,
style::body_font(),
pal.text,
);
}
ui.painter().text(
pos2(rect.right() - 6.0, rect.center().y),
Align2::RIGHT_CENTER,
ws.folder_count(combo.id).to_string(),
style::small_font(),
pal.muted,
);
interactions(&resp, st, combo.id, parent, index, true, draggable, actions);
context_menu(
&resp,
st,
RenameHost::Tree,
combo.id,
true,
&combo.name,
lang,
pal,
actions,
);
if let Some(pending) = drop.claim(rect) {
actions.push(Action::DropIntoNode {
node: combo.id,
paths: pending.paths,
});
} else {
drop.highlight(ui, rect, pal);
}
}
#[allow(clippy::too_many_arguments)]
fn draw_folder_row(
ws: &Workspace,
title: &str,
path: &std::path::Path,
id: u64,
parent: u64,
index: usize,
depth: usize,
ui: &mut egui::Ui,
st: &mut State,
icons: &IconCache,
lang: Lang,
pal: &style::Palette,
m: &style::Metrics,
actions: &mut Vec<Action>,
drop: &mut DropUi,
) {
let (rect, resp) =
ui.allocate_exact_size(vec2(ui.available_width(), m.tree_row), Sense::click_and_drag());
let indent = 4.0 + depth as f32 * 14.0;
let selected = ws.selected == id;
paint_row_bg(ui, rect, resp.hovered() || st.drag == Some(id), selected, pal);
paint_drop_hint(ui, rect, id, index, st, pal);
let icon_rect = Rect::from_center_size(
pos2(rect.left() + indent + 19.0, rect.center().y),
vec2(16.0, 16.0),
);
crate::ui::vector_icons::draw_icon(ui.ctx(), ui.painter(), icons, icon_rect, path, true, pal);
let text_left = rect.left() + indent + 31.0;
let text_clip =
Rect::from_min_max(pos2(text_left, rect.top()), pos2(rect.right() - 6.0, rect.bottom()));
ui.painter().with_clip_rect(text_clip).text(
pos2(text_left, rect.center().y),
Align2::LEFT_CENTER,
title,
style::body_font(),
pal.text,
);
interactions(&resp, st, id, parent, index, false, true, actions);
context_menu(
&resp,
st,
RenameHost::Tree,
id,
false,
title,
lang,
pal,
actions,
);
if let Some(pending) = drop.claim(rect) {
actions.push(Action::DropIntoNode {
node: id,
paths: pending.paths,
});
} else {
drop.highlight(ui, rect, pal);
}
let _ = resp.on_hover_text(path.display().to_string());
}
fn paint_row_bg(ui: &egui::Ui, rect: Rect, hovered: bool, selected: bool, pal: &style::Palette) {
if selected {
ui.painter()
.rect_filled(rect, CornerRadius::same(5), pal.accent_soft);
} else if hovered {
ui.painter()
.rect_filled(rect, CornerRadius::same(5), pal.hover);
}
}
fn paint_drop_hint(
ui: &egui::Ui,
rect: Rect,
row_id: u64,
my_index: usize,
st: &State,
pal: &style::Palette,
) {
if st.drag.is_none() {
return;
}
let Some(target) = st.drop else { return };
if target.row != row_id {
return;
}
if target.into {
ui.painter().rect_stroke(
rect,
CornerRadius::same(5),
Stroke::new(2.0, pal.accent),
egui::StrokeKind::Inside,
);
} else {
let y = if target.index == my_index {
rect.top()
} else {
rect.bottom()
};
ui.painter().line_segment(
[pos2(rect.left() + 4.0, y), pos2(rect.right() - 4.0, y)],
Stroke::new(2.0, pal.accent),
);
}
}
#[allow(clippy::too_many_arguments)]
fn claim_drop(
dragging: Option<u64>,
armed: bool,
id: u64,
parent: u64,
index: usize,
is_combo: bool,
rect: Rect,
pointer: egui::Pos2,
) -> Option<DropTarget> {
let dragged = dragging?;
if !armed || dragged == id || !rect.contains(pointer) {
return None;
}
let rel = ((pointer.y - rect.top()) / rect.height().max(1.0)).clamp(0.0, 1.0);
Some(if is_combo && (0.3..0.7).contains(&rel) {
DropTarget {
parent: id,
index: usize::MAX,
row: id,
into: true,
}
} else if rel < 0.5 {
DropTarget {
parent,
index,
row: id,
into: false,
}
} else {
DropTarget {
parent,
index: index + 1,
row: id,
into: false,
}
})
}
#[allow(clippy::too_many_arguments)]
fn interactions(
resp: &egui::Response,
st: &mut State,
id: u64,
parent: u64,
index: usize,
is_combo: bool,
draggable: bool,
actions: &mut Vec<Action>,
) {
if resp.clicked() {
actions.push(Action::SelectNode(id));
}
if resp.double_clicked() && is_combo {
actions.push(Action::ToggleExpanded(id));
}
if !draggable {
return;
}
if resp.drag_started() {
st.drag = Some(id);
st.drop = None;
st.drag_armed = false;
}
if st.drag == Some(id) && resp.dragged() {
st.drag_armed = true;
}
if let Some(pointer) = resp.ctx.input(|i| i.pointer.latest_pos()) {
if let Some(target) = claim_drop(
st.drag,
st.drag_armed,
id,
parent,
index,
is_combo,
resp.rect,
pointer,
) {
st.drop = Some(target);
}
}
if resp.drag_stopped() {
if let (Some(dragged), Some(target)) = (st.drag, st.drop) {
actions.push(Action::MoveNode {
id: dragged,
parent: target.parent,
index: target.index,
});
}
st.clear_drag();
}
}
#[allow(clippy::too_many_arguments)]
fn context_menu(
resp: &egui::Response,
st: &mut State,
host: RenameHost,
id: u64,
is_combo: bool,
current_name: &str,
lang: Lang,
pal: &style::Palette,
actions: &mut Vec<Action>,
) {
let name = current_name.to_string();
resp.context_menu(|ui| {
if is_combo && ui.button(i18n::t(lang, Key::Rename)).clicked() {
start_rename(ui, st, host, id, &name);
ui.close();
}
if ui.button(i18n::t(lang, Key::DuplicateCombo)).clicked() {
actions.push(Action::DuplicateNode(id));
ui.close();
}
ui.separator();
if is_combo {
if ui.button(i18n::t(lang, Key::AddSubCombo)).clicked() {
actions.push(Action::AddCombo { parent: id });
ui.close();
}
if ui.button(i18n::t(lang, Key::AddFolder)).clicked() {
actions.push(Action::AddFolderFromPicker { parent: id });
ui.close();
}
ui.separator();
ui.label(
RichText::new(i18n::t(lang, Key::Color))
.size(11.5)
.color(pal.muted),
);
ui.horizontal(|ui| {
for color in PALETTE {
let (dot, hit) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::click());
ui.painter().circle_filled(
dot.center(),
5.0,
Color32::from_rgb(color[0], color[1], color[2]),
);
if hit.clicked() {
actions.push(Action::SetNodeColor { id, color });
ui.close();
}
}
});
} else {
if ui.button(i18n::t(lang, Key::RevealInExplorer)).clicked() {
actions.push(Action::RevealNode(id));
ui.close();
}
if ui.button(i18n::t(lang, Key::CopyPath)).clicked() {
actions.push(Action::CopyNodePath(id));
ui.close();
}
}
ui.separator();
if ui.button(i18n::t(lang, Key::DeleteNode)).clicked() {
actions.push(Action::RemoveNode(id));
ui.close();
}
});
}
fn edit_name(
ui: &mut egui::Ui,
st: &mut State,
host: RenameHost,
id: u64,
rect: Rect,
_lang: Lang,
actions: &mut Vec<Action>,
) {
let mut commit = false;
let mut cancel = false;
ui.scope_builder(UiBuilder::new().max_rect(rect), |ui| {
let text = ui.add(
egui::TextEdit::singleline(&mut st.rename_buf)
.id(rename_id(host, id))
.desired_width(ui.available_width())
.font(style::body_font()),
);
if ui.input(|i| i.key_pressed(egui::Key::Escape)) {
cancel = true;
} else if text.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) {
commit = true;
}
});
if cancel {
st.renaming = None;
} else if commit {
actions.push(Action::RenameNode {
id,
name: st.rename_buf.clone(),
});
st.renaming = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn row() -> Rect {
Rect::from_min_size(pos2(100.0, 100.0), vec2(200.0, 20.0))
}
#[test]
fn dragged_row_never_claims_its_own_drop() {
assert_eq!(
claim_drop(Some(7), true, 7, 1, 0, false, row(), pos2(150.0, 110.0)),
None
);
}
#[test]
fn row_claim_follows_pointer_half() {
let r = row();
assert_eq!(
claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 104.0)),
Some(DropTarget {
parent: 3,
index: 2,
row: 9,
into: false
})
);
assert_eq!(
claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 116.0)),
Some(DropTarget {
parent: 3,
index: 3,
row: 9,
into: false
})
);
}
#[test]
fn combo_middle_band_means_drop_into_it() {
let r = row();
assert_eq!(
claim_drop(Some(7), true, 5, 1, 0, true, r, pos2(150.0, 110.0)),
Some(DropTarget {
parent: 5,
index: usize::MAX,
row: 5,
into: true
})
);
let folder = claim_drop(Some(7), true, 5, 1, 0, false, r, pos2(150.0, 110.0));
assert_eq!(folder.map(|t| t.into), Some(false));
assert_eq!(folder.map(|t| t.index), Some(1), "中带偏下 → 插到它后面");
}
#[test]
fn rename_editor_is_only_drawn_by_the_originating_column() {
let mut st = State::default();
st.begin_rename(RenameHost::Projects, 7, "Alpha");
assert!(st.is_renaming(RenameHost::Projects, 7));
assert!(!st.is_renaming(RenameHost::Tree, 7), "树根不能重复画同一个编辑器");
assert!(!st.is_renaming(RenameHost::Projects, 8));
st.begin_rename(RenameHost::Tree, 7, "Alpha");
assert!(st.is_renaming(RenameHost::Tree, 7));
assert!(!st.is_renaming(RenameHost::Projects, 7));
st.renaming = None;
assert!(!st.is_renaming(RenameHost::Tree, 7), "提交/取消后编辑器消失");
}
#[test]
fn project_root_rename_editor_receives_each_key_once() {
let mut ws = Workspace::default();
ws.projects.push(crate::model::Combo::new(1, "Alpha", PALETTE[0]));
ws.active_project = 1;
ws.selected = 1;
let mut st = State::default();
st.begin_rename(RenameHost::Projects, 1, "Alpha");
let mut actions: Vec<Action> = Vec::new();
let mut drop = DropUi::default();
let pal = style::palette(true, PALETTE[0]);
let m = style::metrics(crate::model::Density::Cozy);
let ctx = egui::Context::default();
ctx.memory_mut(|mem| mem.request_focus(rename_id(RenameHost::Projects, 1)));
let raw = || egui::RawInput {
screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(800.0, 600.0))),
..Default::default()
};
let mut draw = |ui: &mut egui::Ui, st: &mut State, actions: &mut Vec<Action>| {
project_row(
&ws,
ws.project(1).unwrap(),
0,
1,
ui,
st,
Lang::Zh,
&pal,
&m,
actions,
);
let root = ws.active_project().clone();
draw_combo_row(
&ws, &root, 0, 0, 0, false, ui, st, Lang::Zh, &pal, &m, actions, &mut drop,
);
};
let mut out = ctx.run_ui(raw(), |ui| draw(ui, &mut st, &mut actions));
out.textures_delta.clear();
let mut input = raw();
input.events.push(egui::Event::Text("X".into()));
let mut out = ctx.run_ui(input, |ui| draw(ui, &mut st, &mut actions));
out.textures_delta.clear();
assert_eq!(st.rename_buf, "AlphaX", "一次按键只能被一个输入框处理");
assert!(actions.is_empty(), "还没按 Enter,不应提交");
}
#[test]
fn rename_editor_ids_do_not_collide_across_columns() {
assert_ne!(
rename_id(RenameHost::Projects, 7),
rename_id(RenameHost::Tree, 7)
);
assert_eq!(
rename_id(RenameHost::Tree, 7),
rename_id(RenameHost::Tree, 7),
"同一栏同一节点的 id 要稳定(焦点请求才能对上)"
);
assert_ne!(
rename_id(RenameHost::Tree, 7),
rename_id(RenameHost::Tree, 8)
);
}
#[test]
fn nothing_claims_without_a_real_drag_or_outside_the_row() {
let r = row();
assert_eq!(
claim_drop(Some(7), false, 9, 3, 2, false, r, pos2(150.0, 104.0)),
None,
"没过拖动阈值(单击)不认领"
);
assert_eq!(
claim_drop(Some(7), true, 9, 3, 2, false, r, pos2(150.0, 300.0)),
None,
"指针不在这一行上"
);
assert_eq!(
claim_drop(None, true, 9, 3, 2, false, r, pos2(150.0, 104.0)),
None,
"根本没在拖"
);
}
}