AnyBot/src/app/sidebar_ui.rs

949 lines
46 KiB
Rust
Raw Normal View History

// 左側欄 and the faces' motion, like the original (captured from Grok Bot itself):
// • top right: two 36 px round buttons, 搜尋 and 新 Bot;
// • the primary bot alone at the top, a big face with a small star badge and its name;
// • the other bots in folders (資料夾; "未分區" for the rest): 54 px rows, 12 px corners, a
// 40 px face, name and last line, section titles 13 px at 66 %;
// • at the bottom: you (initials and name) and 連結應用程式.
// The faces move like the original's mark (renderer engine eo/ia/da): they turn toward the
// pointer (smoothing 1 − 0.86^(dt·60)), blink now and then, bounce when clicked (0.62 s, squash
// and stretch), spin when their bot starts working (0.9 s) and nod when it's done (0.5 s).
// At launch the primary bot greets you in the middle of the window before the app fades in.
use super::*;
/// One face's motion state.
#[derive(Clone, Debug)]
pub struct Face {
gaze: egui::Vec2,
last: f64,
next_blink: f64,
blink_at: f64,
poke: Option<(Poke, f64)>,
}
impl Face {
/// The clock this face last saw (for pokes started outside a frame).
pub fn now(&self) -> f64 {
self.last
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
#[allow(dead_code)]
pub enum Poke {
Spin,
Bounce,
Nod,
/// Glance left, then right (the greeting).
Look,
}
const BLINK: f64 = 0.24;
const SPLASH: f64 = 1.6;
const SPLASH_FADE: f64 = 0.35;
fn duration(p: Poke) -> f64 {
match p {
Poke::Spin => 0.9,
Poke::Bounce => 0.62,
Poke::Nod => 0.5,
Poke::Look => 0.7,
}
}
/// sin² bump between a and b (the original's `aa`).
fn bump(e: f32, a: f32, b: f32) -> f32 {
if e <= a || e >= b {
0.0
} else {
(PI * (e - a) / (b - a)).sin().powi(2)
}
}
use std::f32::consts::PI;
/// Pseudo-random 0..1 from a seed (blinks don't all happen together).
fn rand01(seed: u64) -> f64 {
let mut x = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ 0xD1B5_4A32_D192_ED03;
x ^= x >> 29;
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 32;
(x % 10_000) as f64 / 10_000.0
}
impl App {
/// This face's pose now. `follow`: turn toward the pointer.
pub(super) fn face_pose(&mut self, ctx: &egui::Context, id: u64, rect: egui::Rect, follow: bool) -> avatar::Pose {
let now = ctx.input(|i| i.time);
let pointer = ctx.input(|i| i.pointer.hover_pos());
let f = self.faces.entry(id).or_insert_with(|| Face { gaze: egui::Vec2::ZERO, last: now, next_blink: now + 1.0 + 4.0 * rand01(id), blink_at: -10.0, poke: None });
let dt = (now - f.last).clamp(0.0, 0.1) as f32;
f.last = now;
// Gaze: toward the pointer when it is near (within 2 face widths, like `fa`).
let working = self.streams.contains_key(&id);
let waiting = self.approvals.contains_key(&id);
let pointer_target = match (follow, pointer) {
(true, Some(pp)) => {
let r = rect.width().max(1.0) * 2.0;
let d = pp - rect.center();
if d.length() < r * 3.0 {
vec2((d.x / r).clamp(-1.0, 1.0), (-d.y / r).clamp(-1.0, 1.0))
} else {
egui::Vec2::ZERO
}
}
_ => egui::Vec2::ZERO,
};
// What the eyes say: working → they read slowly left and right; waiting for you → they
// look up at you; otherwise they follow the pointer.
let target = if waiting {
vec2(0.0, 0.85)
} else if working {
let ph = (now * 1.1 + id as f64) as f32;
vec2(ph.sin() * 0.75, -0.35 + 0.08 * (ph * 2.0).sin())
} else {
pointer_target
};
let f = self.faces.get_mut(&id).expect("face");
let k = 1.0 - 0.86f32.powf(dt * 60.0);
f.gaze += (target - f.gaze) * k;
// Blink every 2.5–6.5 s.
if now >= f.next_blink {
f.blink_at = now;
f.next_blink = now + 4.0 + 4.0 * rand01((now * 1000.0) as u64 ^ id);
}
let bt = ((now - f.blink_at) / BLINK) as f32;
let blink = if (0.0..1.0).contains(&bt) { (PI * bt).sin() } else { 0.0 };
let mut pose = avatar::Pose { gaze: f.gaze, blink, ..Default::default() };
if let Some((kind, at)) = f.poke {
let e = ((now - at) / duration(kind)) as f32;
if e >= 1.0 {
f.poke = None;
} else {
match kind {
Poke::Spin => pose.yaw = 180.0 * 2.0 * (1.0 - (PI * e).cos()),
Poke::Nod => pose.pitch = 16.0 * bump(e, 0.0, 1.0),
Poke::Look => pose.gaze = vec2(-(2.0 * PI * e).sin() * 0.9, 0.1),
Poke::Bounce => {
let t = bump(e, 0.0, 0.18) + bump(e, 0.82, 1.0);
let n = bump(e, 0.18, 0.82);
pose.lift = 0.3 * (PI * ((e - 0.14) / 0.72).clamp(0.0, 1.0)).sin();
pose.squash = vec2(1.0 + 0.08 * t - 0.04 * n, 1.0 - 0.1 * t + 0.06 * n);
}
}
}
}
let moving = f.poke.is_some() || (f.gaze - target).length() > 0.002 || blink > 0.0 || working;
if moving {
ctx.request_repaint();
} else {
ctx.request_repaint_after(std::time::Duration::from_secs_f64((f.next_blink - now).max(0.05)));
}
pose
}
pub(super) fn poke(&mut self, id: u64, kind: Poke, now: f64) {
if let Some(f) = self.faces.get_mut(&id) {
f.poke = Some((kind, now));
} else {
self.faces.insert(id, Face { gaze: egui::Vec2::ZERO, last: now, next_blink: now + 2.0, blink_at: -10.0, poke: Some((kind, now)) });
}
}
/// A face drawn with its motion (and, while the bot works, three small dots circling).
pub(super) fn live_face(&mut self, ui: &mut egui::Ui, id: u64, rect: egui::Rect, follow: bool) {
let painter = ui.painter().clone();
let ctx = ui.ctx().clone();
self.paint_face(&ctx, &painter, id, rect, follow);
}
fn paint_face(&mut self, ctx: &egui::Context, painter: &egui::Painter, id: u64, rect: egui::Rect, follow: bool) {
let Some(b) = self.state.bot(id).cloned() else { return };
let pose = self.face_pose(ctx, id, rect, follow);
let col = self.bot_color(&b);
avatar::paint_posed(painter, rect, &b.shape, col, pose);
}
/// The sidebar's contents (the panel itself, its width and dragging live in sidebar()).
/// The bottom row (you, 連結應用程式) has its own fixed strip so it is never cut off.
pub(super) fn sidebar_body(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
// The part of the panel that is really on screen (the window can be shorter than the
// panel thinks while it is being resized).
let visible = ui.clip_rect().intersect(ui.ctx().content_rect());
let full = egui::Rect::from_min_max(ui.max_rect().min, egui::pos2(ui.max_rect().right(), ui.max_rect().bottom().min(visible.bottom() - 34.0)));
let bottom_h = 48.0 + if self.newer_build.is_some() { 38.0 } else { 0.0 };
let top = egui::Rect::from_min_max(full.min, egui::pos2(full.right(), full.bottom() - bottom_h));
let bottom = egui::Rect::from_min_max(egui::pos2(full.left(), full.bottom() - bottom_h), full.max);
ui.scope_builder(egui::UiBuilder::new().max_rect(top), |ui| self.sidebar_list(ui));
ui.scope_builder(egui::UiBuilder::new().max_rect(bottom).layout(Layout::bottom_up(Align::LEFT)), |ui| {
ui.horizontal(|ui| {
ui.set_height(44.0);
let name = self.person_name();
let initials: String = name.split_whitespace().filter_map(|w| w.chars().next()).take(2).collect::<String>().to_uppercase();
let initials = if initials.is_empty() { name.chars().take(1).collect() } else { initials };
let w = ui.available_width() - 46.0;
let (rect, r) = ui.allocate_exact_size(vec2(w, 40.0), egui::Sense::click());
if r.hovered() {
ui.painter().rect_filled(rect, 12, p.hover);
}
let c = rect.left_center() + vec2(20.0, 0.0);
ui.painter().circle_filled(c, 16.0, p.active);
ui.painter().circle_stroke(c, 15.5, Stroke::new(1.0, p.border));
ui.painter().text(c, egui::Align2::CENTER_CENTER, initials, egui::FontId::proportional(12.0), p.text2);
let label = shorten_width(ui, &name, w - 52.0, 14.0);
ui.painter().text(rect.left_center() + vec2(44.0, 0.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(14.0), p.text);
if r.on_hover_text(t("設定(⌘,)")).on_hover_cursor(egui::CursorIcon::PointingHand).clicked() {
self.show_settings = true;
self.settings_tab = SettingsTab::General;
}
if pill_icon(ui, crate::icons::Icon::Apps, p).on_hover_text(t("連接器市集")).clicked() {
self.show_settings = true;
self.settings_tab = SettingsTab::Channels;
self.ch.focus_market = true;
}
});
if let Some(tm) = self.newer_build {
if crate::icons::pill(ui, crate::icons::Icon::Update, &format!("{}({})", t("重新啟動以更新"), version_label(Some(tm))), p.accent, Color32::WHITE, 30.0).clicked() {
self.relaunch_requested = true;
}
}
});
}
/// Top buttons, the primary bot and the groups (scrolling).
fn sidebar_list(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
// 搜尋 / + at the top right. + offers a new bot or a new group.
ui.horizontal(|ui| {
ui.set_height(36.0);
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
ui.spacing_mut().item_spacing.x = 8.0;
let plus = pill_icon(ui, crate::icons::Icon::Plus, p).on_hover_text(t("新增"));
egui::Popup::menu(&plus).show(|ui| {
ui.set_min_width(menu_ui::MENU_W);
ui.spacing_mut().item_spacing.y = 0.0;
if menu_ui::item(ui, p, crate::icons::Icon::Bot, "新增 Bot").clicked() {
self.new_bot();
ui.close();
}
if menu_ui::item(ui, p, crate::icons::Icon::Folder, "新增分區").clicked() {
self.create_section(None);
ui.close();
}
menu_ui::sep(ui, p);
self.new_bot_menu(ui);
});
let open = self.search_open || !self.search.is_empty();
if pill_icon(ui, crate::icons::Icon::Search, p).on_hover_text(t("搜尋 Bot")).clicked() {
self.search_open = !open;
if open {
self.search.clear();
}
}
});
});
if self.search_open || !self.search.is_empty() {
ui.add_space(8.0);
Frame::new().fill(p.hover).corner_radius(10).inner_margin(Margin::symmetric(10, 6)).show(ui, |ui| {
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(16.0, 18.0), egui::Sense::hover());
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(r.center(), vec2(14.0, 14.0)), crate::icons::Icon::Search, p.text3);
let resp = ui.add(egui::TextEdit::singleline(&mut self.search).hint_text(t("搜尋 Bot")).frame(Frame::NONE).desired_width(f32::INFINITY));
if self.search_open && self.search.is_empty() && !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
});
});
}
let q = self.search.trim().to_lowercase();
let matches = |b: &Bot| q.is_empty() || b.name.to_lowercase().contains(&q) || b.description.to_lowercase().contains(&q);
let primary = self.state.bot(self.state.primary_bot).filter(|b| matches(b)).map(|b| b.id);
let pins: Vec<u64> = self.pinned_ids().into_iter().filter(|id| self.state.bot(*id).map(|b| matches(b)).unwrap_or(false)).collect();
let folders = self.all_folders();
let has_sections = !folders.is_empty();
// 分區 in order (None = 未分區); pinned bots show only in the grid.
let mut sections: Vec<(Option<String>, Vec<u64>)> = self.section_order().into_iter().map(|k| (k, vec![])).collect();
for b in self.state.bots.iter().filter(|b| b.id != self.state.primary_bot && !b.pinned && matches(b)) {
let key = if has_sections && folders.contains(&b.folder) { Some(b.folder.clone()) } else { None };
if let Some(s) = sections.iter_mut().find(|s| s.0 == key) {
s.1.push(b.id);
}
}
// While searching, empty sections are hidden.
if !q.is_empty() {
sections.retain(|s| !s.1.is_empty());
}
let dragged_id = egui::DragAndDrop::payload::<u64>(ui.ctx()).map(|x| *x);
let dragging = dragged_id.is_some();
let mut moved: Option<(u64, Option<String>)> = None;
let mut pin_to: Option<(u64, usize)> = None;
let n_sections = sections.len();
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
self.pin_grid(ui, primary, &pins, dragged_id, &mut pin_to);
if primary.is_none() && pins.is_empty() && sections.iter().all(|s| s.1.is_empty()) && !q.is_empty() {
ui.add_space(12.0);
ui.label(RichText::new(t("沒有相符的 Bot")).color(p.text3));
}
for (si, (folder, ids)) in sections.into_iter().enumerate() {
let is_loose = folder.is_none();
// 未分區 hides when empty (unless a bot is being dragged).
if is_loose && has_sections && ids.is_empty() && !dragging {
continue;
}
if !has_sections && ids.is_empty() && !dragging {
continue;
}
let title = folder.clone().unwrap_or_else(|| if has_sections { t("未分區") } else { "Bot".into() });
let start = ui.cursor().top();
ui.add_space(14.0);
let renaming = self.renaming.as_ref().filter(|r| Some(&r.0) == folder.as_ref()).cloned();
let mut folded_now = false;
match renaming {
Some((old, mut buf)) => {
// Inline rename: Enter or clicking away saves, Esc cancels.
let resp = ui.horizontal(|ui| {
ui.add_space(6.0);
ui.add(egui::TextEdit::singleline(&mut buf).desired_width(ui.available_width() - 12.0).font(egui::FontId::proportional(13.0)))
}).inner;
if !resp.has_focus() && !resp.lost_focus() {
resp.request_focus();
}
if resp.lost_focus() {
self.renaming = None;
let cancel = ui.input(|i| i.key_pressed(Key::Escape));
let new = buf.trim().to_string();
if !cancel && !new.is_empty() && new != old && !self.all_folders().contains(&new) {
self.rename_folder(&old, &new);
}
} else {
self.renaming = Some((old, buf));
}
}
None => {
let key = folder.clone().unwrap_or_default();
let folded = self.state.folded.contains(&key) && has_sections;
folded_now = folded;
let (rect, head) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), egui::Sense::click());
let hovered = ui.rect_contains_pointer(rect);
if hovered {
ui.painter().rect_filled(rect, 8, p.hover);
}
let tcol = p.text.gamma_multiply(0.66);
ui.painter().text(rect.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, &title, egui::FontId::proportional(13.0), tcol);
let tw = ui.painter().layout_no_wrap(title.clone(), egui::FontId::proportional(13.0), tcol).size().x;
if has_sections && (hovered || folded) {
let c = rect.left_center() + vec2(8.0 + tw + 10.0, 0.0);
let st = Stroke::new(1.3, tcol);
if folded {
ui.painter().line_segment([c + vec2(-2.0, -3.5), c + vec2(1.5, 0.0)], st);
ui.painter().line_segment([c + vec2(-2.0, 3.5), c + vec2(1.5, 0.0)], st);
} else {
ui.painter().line_segment([c + vec2(-3.5, -1.5), c + vec2(0.0, 2.0)], st);
ui.painter().line_segment([c + vec2(3.5, -1.5), c + vec2(0.0, 2.0)], st);
}
}
if folded {
ui.painter().text(rect.right_center() - vec2(10.0, 0.0), egui::Align2::RIGHT_CENTER, format!("{}", ids.len()), egui::FontId::proportional(13.0), tcol);
}
// ⋯ on hover opens the same menu as a right-click.
let br = egui::Rect::from_center_size(rect.right_center() - vec2(if folded { 34.0 } else { 14.0 }, 0.0), vec2(22.0, 22.0));
let menu_key = folder.clone().unwrap_or_else(|| "\u{0}loose".into());
let mut menu_btn = None;
if hovered || self.group_menu_for.as_deref() == Some(menu_key.as_str()) {
let mr = ui.interact(br, egui::Id::unique(("section-more", &menu_key)), egui::Sense::click());
if mr.hovered() {
ui.painter().rect_filled(br, 6, p.active);
}
crate::icons::paint(ui.painter(), egui::Rect::from_center_size(br.center(), vec2(14.0, 14.0)), crate::icons::Icon::More, tcol);
menu_btn = Some(mr.on_hover_text(t("分區操作")));
}
let menu_clicked = menu_btn.as_ref().map(|m| m.clicked()).unwrap_or(false);
if head.clicked() && !menu_clicked && has_sections {
if folded {
self.state.folded.retain(|x| *x != key);
} else {
self.state.folded.push(key.clone());
}
self.mark();
}
if head.double_clicked() {
if let Some(f) = folder.clone() {
self.renaming = Some((f.clone(), f));
}
}
let (first, last) = (si == 0, si + 1 == n_sections);
let f2 = folder.clone();
let edit = |this: &mut Self, ui: &mut egui::Ui| {
ui.set_min_width(180.0);
if !has_sections {
// The flat list: start the first 分區 here.
if menu_ui::item(ui, p, crate::icons::Icon::Folder, "建立分區").clicked() {
this.create_section(None);
ui.close();
}
return;
}
if let Some(f) = &f2 {
if menu_ui::item(ui, p, crate::icons::Icon::Pencil, "重新命名").clicked() {
this.renaming = Some((f.clone(), f.clone()));
ui.close();
}
}
if ui.add_enabled_ui(!first, |ui| menu_ui::item(ui, p, crate::icons::Icon::ArrowUp, "上移")).inner.clicked() {
this.move_section(&f2, -1);
ui.close();
}
if ui.add_enabled_ui(!last, |ui| menu_ui::item(ui, p, crate::icons::Icon::ArrowDown, "下移")).inner.clicked() {
this.move_section(&f2, 1);
ui.close();
}
if let Some(f) = &f2 {
menu_ui::sep(ui, p);
if menu_ui::danger(ui, p, crate::icons::Icon::Trash, "刪除").clicked() {
this.confirm_section_delete = Some(f.clone());
ui.close();
}
}
};
head.context_menu(|ui| edit(self, ui));
if let Some(mr) = menu_btn {
if mr.clicked() {
self.group_menu_for = Some(menu_key.clone());
}
let open = egui::Popup::menu(&mr).show(|ui| edit(self, ui));
if open.is_none() && self.group_menu_for.as_deref() == Some(menu_key.as_str()) && !mr.clicked() {
self.group_menu_for = None;
}
}
}
}
if !folded_now {
ui.add_space(6.0);
if ids.is_empty() && folder.is_some() {
ui.horizontal(|ui| {
ui.add_space(8.0);
ui.label(RichText::new(t(if dragging { "放到這裡" } else { "將 Bot 拖曳到這裡" })).size(12.0).color(p.text3));
});
}
for id in ids {
self.bot_row(ui, id);
}
}
// The whole section is a drop target while a bot (or a pinned tile) is dragged.
let area = egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), start), egui::pos2(ui.max_rect().right(), ui.cursor().top()));
let zone = ui.interact(area, egui::Id::unique(("section-drop", si, folder.clone())), egui::Sense::hover());
if zone.dnd_hover_payload::<u64>().map(|id| *id != self.state.primary_bot).unwrap_or(false) {
ui.painter().rect_stroke(area.shrink(1.0), 12, Stroke::new(1.5, p.accent), egui::StrokeKind::Inside);
}
if let Some(id) = zone.dnd_release_payload::<u64>() {
if *id != self.state.primary_bot {
moved = Some((*id, folder.clone()));
}
}
}
ui.add_space(8.0);
});
if let Some((id, f)) = moved {
// Dropping a pinned tile on a section unpins it and moves it there.
self.set_pinned(id, false, None);
if let Some(b) = self.state.bot_mut(id) {
b.folder = f.unwrap_or_default();
}
self.mark();
}
if let Some((id, at)) = pin_to {
self.set_pinned(id, true, Some(at));
}
// 刪除分區: asked first; its bots go back to 未分區.
if let Some(name) = self.confirm_section_delete.clone() {
let ctx = ui.ctx().clone();
let mut keep = true;
let close = dialog(&ctx, "section-delete", &format!("刪除「{name}」"), 400.0, true, p, |ui| {
dialog_body(ui, "section-delete-body", 160.0, |ui| {
ui.label(t("其中的 Bot 會移至 未分區,不會刪除任何 Bot。"));
});
dialog_footer(ui, p, |ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add(danger_button("刪除", p)).clicked() {
self.delete_folder(&name);
keep = false;
}
if ui.add(secondary_button("取消")).clicked() {
keep = false;
}
});
});
});
if !keep || close {
self.confirm_section_delete = None;
}
}
// The dragged bot follows the pointer.
if let (Some(id), Some(pos)) = (egui::DragAndDrop::payload::<u64>(ui.ctx()), ui.ctx().pointer_hover_pos()) {
if let Some(b) = self.state.bot(*id).cloned() {
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("drag-bot")));
let rect = egui::Rect::from_min_size(pos + vec2(8.0, 4.0), vec2(170.0, 36.0));
painter.rect_filled(rect, 12, p.elevated);
painter.rect_stroke(rect, 12, Stroke::new(1.0, p.border), egui::StrokeKind::Inside);
avatar::paint(&painter, egui::Rect::from_min_size(rect.min + vec2(8.0, 6.0), vec2(24.0, 24.0)), &b.shape, self.bot_color(&b));
painter.text(rect.left_center() + vec2(40.0, 0.0), egui::Align2::LEFT_CENTER, shorten_width(ui, &b.name, 118.0, 13.5), egui::FontId::proportional(13.5), p.text);
}
}
}
/// Every group, in order (saved ones first, then any a bot names).
pub(super) fn all_folders(&self) -> Vec<String> {
let mut v = self.state.folders.clone();
for b in &self.state.bots {
if !b.folder.is_empty() && !v.contains(&b.folder) {
v.push(b.folder.clone());
}
}
v
}
fn rename_folder(&mut self, old: &str, new: &str) {
for f in self.state.folders.iter_mut() {
if f == old {
*f = new.to_string();
}
}
if !self.state.folders.iter().any(|f| f == new) {
self.state.folders.push(new.to_string());
}
for b in self.state.bots.iter_mut() {
if b.folder == old {
b.folder = new.to_string();
}
}
self.mark();
}
/// 分區 in display order; None is 未分區.
pub(super) fn section_order(&self) -> Vec<Option<String>> {
let mut v: Vec<Option<String>> = self.all_folders().into_iter().map(Some).collect();
let at = self.state.loose_index.unwrap_or(v.len()).min(v.len());
v.insert(at, None);
v
}
fn move_section(&mut self, key: &Option<String>, by: i32) {
let mut order = self.section_order();
if let Some(i) = order.iter().position(|x| x == key) {
let j = (i as i32 + by).clamp(0, order.len() as i32 - 1) as usize;
order.swap(i, j);
self.state.loose_index = order.iter().position(|x| x.is_none());
self.state.folders = order.into_iter().flatten().collect();
self.mark();
}
}
/// A new 分區 at the top (新增分區, 新增分區 2…), renaming right away; `bot` moves into it.
pub(super) fn create_section(&mut self, bot: Option<u64>) {
let all = self.all_folders();
let base = t("新增分區");
let mut n = 1;
let name = loop {
let c = if n == 1 { base.clone() } else { format!("{base} {n}") };
if !all.contains(&c) {
break c;
}
n += 1;
};
let mut folders = all;
folders.insert(0, name.clone());
if let Some(i) = self.state.loose_index.as_mut() {
*i += 1;
}
self.state.folders = folders;
if let Some(id) = bot {
self.set_pinned(id, false, None);
if let Some(b) = self.state.bot_mut(id) {
b.folder = name.clone();
}
}
self.renaming = Some((name.clone(), name));
self.mark();
}
/// 已釘選的 Bot in grid order (the primary bot has its own tile first).
pub(super) fn pinned_ids(&self) -> Vec<u64> {
let mut v: Vec<u64> = self.state.pin_order.iter().copied().filter(|id| *id != self.state.primary_bot && self.state.bot(*id).map(|b| b.pinned).unwrap_or(false)).collect();
for b in &self.state.bots {
if b.pinned && b.id != self.state.primary_bot && !v.contains(&b.id) {
v.push(b.id);
}
}
v
}
/// Pin (at a place in the grid, default the end) or unpin a bot.
pub(super) fn set_pinned(&mut self, id: u64, on: bool, at: Option<usize>) {
let mut order = self.pinned_ids();
order.retain(|x| *x != id);
if on && id != self.state.primary_bot {
let at = at.unwrap_or(order.len()).min(order.len());
order.insert(at, id);
}
if let Some(b) = self.state.bot_mut(id) {
b.pinned = on;
}
self.state.pin_order = order;
self.mark();
}
/// The grid at the top: the primary bot, then 已釘選的 Bot. Bots dragged here are pinned;
/// tiles dragged within it are reordered (a tile dragged to a 分區 is unpinned there).
fn pin_grid(&mut self, ui: &mut egui::Ui, primary: Option<u64>, pins: &[u64], dragged: Option<u64>, pin_to: &mut Option<(u64, usize)>) {
let p = self.pal;
let tiles: Vec<u64> = primary.into_iter().chain(pins.iter().copied()).collect();
let dragging_other = dragged.map(|d| d != self.state.primary_bot).unwrap_or(false);
if tiles.is_empty() && !dragging_other {
return;
}
ui.add_space(10.0);
let w = ui.available_width();
let cols = ((w / 92.0).floor() as usize).max(1);
let tile_w = w / cols as f32;
let tile_h = 100.0;
// Room for one more tile while a bot that isn't pinned yet is dragged over.
let showing_zone = dragging_other && pins.is_empty() && !dragged.map(|d| pins.contains(&d)).unwrap_or(false);
let rows = tiles.len().div_ceil(cols).max(1);
let grid_h = rows as f32 * tile_h + if showing_zone { 58.0 } else { 0.0 };
let (area, _) = ui.allocate_exact_size(vec2(w, grid_h), egui::Sense::hover());
let rect_of = |i: usize| egui::Rect::from_min_size(area.min + vec2((i % cols) as f32 * tile_w, (i / cols) as f32 * tile_h), vec2(tile_w, tile_h));
for (i, id) in tiles.iter().enumerate() {
self.pin_tile(ui, rect_of(i), *id, Some(*id) == primary);
}
if showing_zone {
let zr = egui::Rect::from_min_size(egui::pos2(area.left() + 6.0, area.bottom() - 54.0), vec2(w - 12.0, 48.0));
ui.painter().rect_stroke(zr, 12, Stroke::new(1.2, p.border), egui::StrokeKind::Inside);
ui.painter().text(zr.center(), egui::Align2::CENTER_CENTER, t("拖曳到此處以釘選"), egui::FontId::proportional(12.5), p.text3);
}
let zone = ui.interact(area, egui::Id::unique("pin-grid-drop"), egui::Sense::hover());
let over = zone.dnd_hover_payload::<u64>().map(|id| *id != self.state.primary_bot).unwrap_or(false);
// Where it would land: before the tile under the pointer (after the primary tile).
let index_at = |pos: egui::Pos2| -> usize {
let first = if primary.is_some() { 1 } else { 0 };
let mut idx = pins.len();
for (i, _) in pins.iter().enumerate() {
let r = rect_of(i + first);
if (pos.y < r.bottom() && pos.x < r.center().x) || pos.y < r.top() {
idx = i;
break;
}
}
idx
};
if over {
ui.painter().rect_stroke(area.shrink(1.0), 12, Stroke::new(1.5, p.accent), egui::StrokeKind::Inside);
if let Some(pos) = ui.ctx().pointer_hover_pos() {
let i = index_at(pos) + if primary.is_some() { 1 } else { 0 };
if !pins.is_empty() {
let r = rect_of(i.min(tiles.len()));
let x = if i >= tiles.len() { rect_of(tiles.len() - 1).right() } else { r.left() };
let r = if i >= tiles.len() { rect_of(tiles.len() - 1) } else { r };
ui.painter().vline(x, r.y_range().shrink(14.0), Stroke::new(2.0, p.accent));
}
}
}
if let Some(id) = zone.dnd_release_payload::<u64>() {
if *id != self.state.primary_bot {
let at = ui.ctx().pointer_interact_pos().map(index_at).unwrap_or(pins.len());
// Moving a tile later in the grid: its own slot frees up first.
let at = match pins.iter().position(|x| x == &*id) {
Some(from) if from < at => at - 1,
_ => at,
};
*pin_to = Some((*id, at));
}
}
}
/// One tile of the grid: a big face and the name (★ for the primary bot).
fn pin_tile(&mut self, ui: &mut egui::Ui, rect: egui::Rect, id: u64, is_primary: bool) {
let p = self.pal;
let Some(b) = self.state.bot(id).cloned() else { return };
let sense = if is_primary { egui::Sense::click() } else { egui::Sense::click_and_drag() };
let hit = egui::Rect::from_center_size(rect.center(), vec2(rect.width().min(104.0) - 4.0, 96.0));
let r = ui.interact(hit, egui::Id::unique(("pin-tile", id)), sense);
if !is_primary {
r.dnd_set_drag_payload(id);
}
let selected = self.state.selected == id;
if selected || r.hovered() {
ui.painter().rect_filled(hit, 14, if selected { p.active } else { p.hover });
}
let face = egui::Rect::from_center_size(rect.center_top() + vec2(0.0, 36.0), vec2(56.0, 56.0));
self.live_face(ui, id, face, true);
if is_primary {
let badge = face.right_bottom() + vec2(-6.0, -6.0);
ui.painter().circle_filled(badge, 9.0, p.sidebar);
ui.painter().circle_filled(badge, 7.5, Color32::from_rgb(0xf9, 0x73, 0x16));
let star: Vec<egui::Pos2> = (0..10).map(|i| {
let a = -std::f32::consts::FRAC_PI_2 + i as f32 * std::f32::consts::PI / 5.0;
let rr = if i % 2 == 0 { 4.6 } else { 2.0 };
badge + vec2(a.cos() * rr, a.sin() * rr)
}).collect();
ui.painter().add(egui::Shape::convex_polygon(star.clone(), Color32::WHITE, Stroke::NONE));
for i in 0..5 {
ui.painter().add(egui::Shape::convex_polygon(vec![badge, star[(2 * i + 9) % 10], star[2 * i], star[(2 * i + 1) % 10]], Color32::WHITE, Stroke::NONE));
}
}
let name = shorten_width(ui, &b.name, hit.width() - 8.0, 13.0);
ui.painter().text(rect.center_top() + vec2(0.0, 80.0), egui::Align2::CENTER_CENTER, name, egui::FontId::proportional(13.0), p.text);
if b.unread || self.approvals.contains_key(&id) {
ui.painter().circle_filled(face.right_top() + vec2(4.0, 2.0), 4.5, p.accent);
}
let r = r.on_hover_text(if is_primary { t("主要 Bot") } else { b.name.clone() }).on_hover_cursor(egui::CursorIcon::PointingHand);
if r.clicked() {
self.select_bot(ui.ctx(), id);
}
r.context_menu(|ui| self.bot_menu_items(ui, id));
}
fn delete_folder(&mut self, f: &str) {
let order = self.section_order();
let gone_before_loose = order.iter().position(|x| x.as_deref() == Some(f)).zip(order.iter().position(|x| x.is_none())).map(|(a, b)| a < b).unwrap_or(false);
if gone_before_loose {
if let Some(i) = self.state.loose_index.as_mut() {
*i = i.saturating_sub(1);
}
}
self.state.folders.retain(|x| x != f);
for b in self.state.bots.iter_mut() {
if b.folder == f {
b.folder.clear();
}
}
self.mark();
}
/// One bot in a folder (the original: 54 px, 12 px corners, 40 px face).
pub(super) fn bot_row(&mut self, ui: &mut egui::Ui, id: u64) {
let p = self.pal;
let Some(b) = self.state.bot(id).cloned() else { return };
let selected = self.state.selected == id;
let streaming = self.streams.contains_key(&id);
let waiting = self.approvals.contains_key(&id);
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 54.0), egui::Sense::click_and_drag());
// Drag a bot onto another group to move it.
resp.dnd_set_drag_payload(id);
let dragged = resp.dragged();
let fill = if selected { p.active } else if resp.hovered() || dragged { p.hover } else { Color32::TRANSPARENT };
ui.painter().rect_filled(rect, 12, fill);
let face = egui::Rect::from_min_size(rect.min + vec2(8.0, 7.0), vec2(40.0, 40.0));
self.live_face(ui, id, face, true);
let tx = rect.left() + 56.0;
let text_w = (rect.right() - 22.0 - tx).max(10.0);
let painter = ui.painter().with_clip_rect(rect.intersect(ui.clip_rect()));
let one_line = |text: String, size: f32, color: Color32| {
let mut job = egui::text::LayoutJob::single_section(text, egui::TextFormat::simple(egui::FontId::proportional(size), color));
job.wrap = egui::text::TextWrapping::truncate_at_width(text_w);
painter.layout_job(job)
};
painter.galley(egui::pos2(tx, rect.top() + 9.0), one_line(b.name.clone(), 15.0, p.text), p.text);
let sub = if waiting {
t("需要你允許")
} else if streaming {
t("正在工作…")
} else {
b.messages.iter().rev().find(|m| m.role != Role::Tool && !m.text.is_empty()).map(|m| first_line(&m.text, 80)).filter(|s| !s.is_empty()).unwrap_or_else(|| b.description.clone())
};
let sub_col = if waiting { p.accent } else { p.text3 };
painter.galley(egui::pos2(tx, rect.top() + 30.0), one_line(sub, 13.0, sub_col), sub_col);
if waiting {
ui.painter().circle_filled(egui::pos2(rect.right() - 12.0, rect.center().y), 5.0, p.accent);
ui.painter().circle_stroke(egui::pos2(rect.right() - 12.0, rect.center().y), 7.5, Stroke::new(1.5, p.accent.gamma_multiply(0.4)));
} else if b.unread {
ui.painter().circle_filled(egui::pos2(rect.right() - 12.0, rect.center().y), 4.0, Color32::from_rgb(0x16, 0x80, 0xf3));
}
if resp.clicked() {
self.select_bot(ui.ctx(), id);
}
resp.context_menu(|ui| self.bot_menu_items(ui, id));
}
fn select_bot(&mut self, ctx: &egui::Context, id: u64) {
self.state.selected = id;
if let Some(b) = self.state.bot_mut(id) {
b.unread = false;
}
self.stick_bottom = true;
let now = ctx.input(|i| i.time);
self.poke(id, Poke::Bounce, now);
self.mark();
}
/// The name shown at the bottom left: the account, else the Mac's user.
fn person_name(&self) -> String {
if !self.state.remote.relay_user.is_empty() {
return self.state.remote.relay_user.clone();
}
static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
NAME.get_or_init(|| {
std::process::Command::new("id").arg("-F").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| std::env::var("USER").unwrap_or_else(|_| "你".into()))
})
.clone()
}
/// Group chat and templates (in +).
pub(super) fn new_bot_menu(&mut self, ui: &mut egui::Ui) {
let p = self.pal;
if menu_ui::item(ui, p, crate::icons::Icon::Users, "群組聊天").on_hover_text(t("群組聊天(好幾個 Bot 一起回答)")).clicked() {
let id = self.state.next_id;
let members: Vec<u64> = self.state.bots.iter().filter(|b| b.members.is_empty()).map(|b| b.id).take(3).collect();
let sel = self.state.selected;
let _ = crate::appctl::apply(&mut self.state, sel, "create_bot", &serde_json::json!({"name": "群組聊天", "description": "成員依序回答"}).to_string());
if let Some(b) = self.state.bot_mut(id) {
b.members = members;
b.shape = "cloud".into();
}
self.state.selected = id;
self.edit_bot = self.state.bot(id).cloned().map(|b| (b, false));
self.mark();
ui.close();
}
menu_ui::sub(ui, p, crate::icons::Icon::Template, "從範本建立", |ui| {
ui.set_min_width(260.0);
for tp in crate::memory::TEMPLATES.iter() {
if menu_ui::item_text(ui, p, None, tp.name).on_hover_text(tp.description).clicked() {
let id = self.state.next_id;
let sel = self.state.selected;
let _ = crate::appctl::apply(&mut self.state, sel, "create_bot", &serde_json::json!({"template": tp.name}).to_string());
if self.state.bot(id).is_some() {
self.state.selected = id;
self.edit_bot = self.state.bot(id).cloned().map(|b| (b, false));
}
self.mark();
ui.close();
}
}
});
}
/// 移至 in a bot's menu: every 分區 (✓ the current one, 未分區 too), then 建立分區. With no
/// 分區 yet it is one item, 移至新分區. Pinned bots have no 移至 (they live in the grid).
pub(super) fn folder_menu(&mut self, ui: &mut egui::Ui, id: u64) {
let Some(b) = self.state.bot(id).cloned() else { return };
if b.pinned || id == self.state.primary_bot {
return;
}
let p = self.pal;
let folders = self.all_folders();
if folders.is_empty() {
if menu_ui::item(ui, p, crate::icons::Icon::Folder, "移至新分區").clicked() {
self.create_section(Some(id));
ui.close();
}
return;
}
let current = if folders.contains(&b.folder) { b.folder.clone() } else { String::new() };
let order = self.section_order();
menu_ui::sub(ui, p, crate::icons::Icon::Folder, "移至", |ui| {
let mut pick: Option<String> = None;
for k in &order {
let key = k.clone().unwrap_or_default();
let label = k.clone().unwrap_or_else(|| t("未分區"));
let icon = if key == current { crate::icons::Icon::Check } else { crate::icons::Icon::Folder };
if menu_ui::item_text(ui, p, Some(icon), &label).clicked() {
pick = Some(key);
}
}
menu_ui::sep(ui, p);
if menu_ui::item(ui, p, crate::icons::Icon::Plus, "建立分區").clicked() {
self.create_section(Some(id));
ui.close();
}
if let Some(f) = pick {
if let Some(b) = self.state.bot_mut(id) {
b.folder = f;
}
self.mark();
ui.close();
}
});
}
/// 啟動動畫: the primary bot greets you in the middle (a spin, a hop and a blink), then the
/// window fades in. Returns true while it is showing.
pub(super) fn splash(&mut self, ctx: &egui::Context) -> bool {
if self.demo && std::env::var("ANYBOT_SHOT_SPLASH").is_err() {
return false;
}
// No bot yet to say hello.
if self.state.bot(self.state.primary_bot).is_none() {
return false;
}
let now = ctx.input(|i| i.time);
let start = *self.splash_at.get_or_insert(now);
let t = now - start;
if t > SPLASH + SPLASH_FADE {
return false;
}
let p = self.pal;
let id = self.state.primary_bot;
// Grow in, look around once (left, right), hop, then blink — each step easy to see.
if (0.55..0.6).contains(&t) && self.faces.get(&id).map(|f| f.poke.is_none()).unwrap_or(true) {
self.poke(id, Poke::Look, now);
}
if (1.25..1.3).contains(&t) && self.faces.get(&id).map(|f| f.poke.is_none()).unwrap_or(true) {
self.poke(id, Poke::Bounce, now);
}
let fade = if t > SPLASH { 1.0 - ((t - SPLASH) / SPLASH_FADE) as f32 } else { 1.0 };
// Painted straight onto the top layer: covers the whole window from the first frame.
let screen = ctx.content_rect();
let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::unique("splash")));
painter.rect_filled(screen, 0, p.pane.gamma_multiply(fade));
let grow = (1.0 - (1.0 - (t / 0.4).min(1.0) as f32).powi(3)).max(0.01);
let size = 120.0 * grow;
let face = egui::Rect::from_center_size(screen.center() - vec2(0.0, 24.0), vec2(size, size));
let mut faded = painter.clone();
faded.set_opacity(fade);
self.paint_face(ctx, &faded, id, face, true);
if let Some(b) = self.state.bot(id) {
let a = ((t - 0.35) / 0.4).clamp(0.0, 1.0) as f32 * fade;
painter.text(screen.center() + vec2(0.0, 62.0), egui::Align2::CENTER_CENTER, &b.name, egui::FontId::proportional(18.0), p.text.gamma_multiply(a));
}
// Clicks wait until it has faded.
if t < SPLASH {
egui::Area::new(egui::Id::unique("splash-block")).order(egui::Order::Foreground).fixed_pos(screen.min).show(ctx, |ui| {
ui.allocate_exact_size(screen.size(), egui::Sense::click());
});
}
ctx.request_repaint();
true
}
}
pub(super) fn shorten_width(ui: &egui::Ui, s: &str, max_w: f32, size: f32) -> String {
let w = |x: &str| ui.painter().layout_no_wrap(x.to_string(), egui::FontId::proportional(size), Color32::WHITE).size().x;
if w(s) <= max_w {
return s.to_string();
}
let chars: Vec<char> = s.chars().collect();
let mut n = chars.len();
while n > 1 {
n -= 1;
let c = chars[..n].iter().collect::<String>() + "…";
if w(&c) <= max_w {
return c;
}
}
"…".into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_and_rand() {
assert_eq!(bump(0.0, 0.0, 1.0), 0.0);
assert!((bump(0.5, 0.0, 1.0) - 1.0).abs() < 1e-5);
assert!((0.0..1.0).contains(&rand01(42)));
assert_ne!(rand01(1), rand01(2));
}
}