// 左側欄 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)] pub enum Poke { 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::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::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::().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 = self.pinned_ids().into_iter().filter(|id| self.state.bot(*id).is_some_and(matches)).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, Vec)> = 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::(ui.ctx()).map(|x| *x); let dragging = dragged_id.is_some(); let mut moved: Option<(u64, Option)> = 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::().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::() { 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::(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 { 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> { let mut v: Vec> = 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, 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) { 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 { let mut v: Vec = 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) { 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, pins: &[u64], dragged: Option, pin_to: &mut Option<(u64, usize)>) { let p = self.pal; let tiles: Vec = 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::().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::() { 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 = (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 { if self.approvals.get(&b.id).map(|a| a.kind == crate::providers::ApprovalKind::Handoff).unwrap_or(false) { t("等你處理") } else { 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 = 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 = 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 = 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 = s.chars().collect(); let mut n = chars.len(); while n > 1 { n -= 1; let c = chars[..n].iter().collect::() + "…"; 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)); } }