AnyBot/src/app/sidebar_ui.rs

945 lines
46 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 左側欄 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::<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 {
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<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));
}
}