945 lines
46 KiB
Rust
945 lines
46 KiB
Rust
|
|
// 左側欄 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;
|
|||
|
|
}
|
|||
|
|
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));
|
|||
|
|
}
|
|||
|
|
}
|