use std::collections::HashSet;
use std::hash::Hash;
use egui::epaint::text::{LayoutJob, TextFormat};
use egui::text::CCursor;
use egui::{
Align2, Color32, CornerRadius, Event, FontFamily, FontId, Id, Key, Modifiers, Pos2, Rect,
Response, Sense, Stroke, StrokeKind, Ui, Vec2, WidgetInfo, WidgetType,
};
use crate::theme::{Palette, Theme, Typography};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum TerminalStatus {
Connected,
Reconnecting,
Offline,
}
impl TerminalStatus {
pub fn indicator_state(self) -> crate::IndicatorState {
match self {
Self::Connected => crate::IndicatorState::On,
Self::Reconnecting => crate::IndicatorState::Connecting,
Self::Offline => crate::IndicatorState::Off,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum LineKind {
Out,
Info,
Ok,
Warn,
Err,
Dim,
Command {
user: String,
host: String,
cwd: String,
cmd: String,
},
}
#[derive(Clone, Debug)]
pub struct TerminalLine {
pub kind: LineKind,
pub text: String,
}
impl TerminalLine {
pub fn new(kind: LineKind, text: impl Into<String>) -> Self {
Self {
kind,
text: text.into(),
}
}
pub fn out(text: impl Into<String>) -> Self {
Self::new(LineKind::Out, text)
}
pub fn info(text: impl Into<String>) -> Self {
Self::new(LineKind::Info, text)
}
pub fn ok(text: impl Into<String>) -> Self {
Self::new(LineKind::Ok, text)
}
pub fn warn(text: impl Into<String>) -> Self {
Self::new(LineKind::Warn, text)
}
pub fn err(text: impl Into<String>) -> Self {
Self::new(LineKind::Err, text)
}
pub fn dim(text: impl Into<String>) -> Self {
Self::new(LineKind::Dim, text)
}
pub fn command(
user: impl Into<String>,
host: impl Into<String>,
cwd: impl Into<String>,
cmd: impl Into<String>,
) -> Self {
Self {
kind: LineKind::Command {
user: user.into(),
host: host.into(),
cwd: cwd.into(),
cmd: cmd.into(),
},
text: String::new(),
}
}
}
#[derive(Clone, Debug)]
pub struct TerminalPane {
pub id: String,
pub host: String,
pub user: String,
pub cwd: String,
pub status: TerminalStatus,
pub lines: Vec<TerminalLine>,
}
impl TerminalPane {
pub fn new(id: impl Into<String>, host: impl Into<String>) -> Self {
Self {
id: id.into(),
host: host.into(),
user: "user".into(),
cwd: "~".into(),
status: TerminalStatus::Connected,
lines: Vec::new(),
}
}
#[inline]
pub fn user(mut self, user: impl Into<String>) -> Self {
self.user = user.into();
self
}
#[inline]
pub fn cwd(mut self, cwd: impl Into<String>) -> Self {
self.cwd = cwd.into();
self
}
#[inline]
pub fn status(mut self, status: TerminalStatus) -> Self {
self.status = status;
self
}
#[inline]
pub fn push(mut self, line: TerminalLine) -> Self {
self.lines.push(line);
self
}
pub fn push_line(&mut self, line: TerminalLine) {
self.lines.push(line);
}
pub fn set_status(&mut self, status: TerminalStatus) {
self.status = status;
}
pub fn command_line(&self, cmd: impl Into<String>) -> TerminalLine {
TerminalLine::command(self.user.clone(), self.host.clone(), self.cwd.clone(), cmd)
}
}
#[derive(Clone, Debug)]
pub enum TerminalEvent {
Command {
targets: Vec<String>,
command: String,
},
}
#[must_use = "Call `.show(ui)` to render the widget."]
pub struct MultiTerminal {
id_salt: Id,
panes: Vec<TerminalPane>,
broadcast: HashSet<String>,
collapsed: HashSet<String>,
stashed: Option<HashSet<String>>,
focused_id: Option<String>,
pending: String,
pending_cursor: usize,
history: Vec<String>,
history_cursor: Option<usize>,
history_cap: usize,
columns_mode: ColumnsMode,
pane_min_height: f32,
scrollback_cap: usize,
auto_focus: bool,
events: Vec<TerminalEvent>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum ColumnsMode {
Fixed(usize),
Auto {
min_col_width: f32,
},
}
impl std::fmt::Debug for MultiTerminal {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultiTerminal")
.field("id_salt", &self.id_salt)
.field("panes", &self.panes.len())
.field("broadcast", &self.broadcast)
.field("collapsed", &self.collapsed)
.field("focused_id", &self.focused_id)
.field("pending", &self.pending)
.field("history", &self.history.len())
.field("columns_mode", &self.columns_mode)
.field("events", &self.events.len())
.finish()
}
}
impl MultiTerminal {
pub fn new(id_salt: impl Hash) -> Self {
Self {
id_salt: Id::new(("elegance_multi_terminal", id_salt)),
panes: Vec::new(),
broadcast: HashSet::new(),
collapsed: HashSet::new(),
stashed: None,
focused_id: None,
pending: String::new(),
pending_cursor: 0,
history: Vec::new(),
history_cursor: None,
history_cap: 200,
columns_mode: ColumnsMode::Fixed(2),
pane_min_height: 220.0,
scrollback_cap: 500,
auto_focus: true,
events: Vec::new(),
}
}
#[inline]
pub fn with_pane(mut self, pane: TerminalPane) -> Self {
self.add_pane(pane);
self
}
#[inline]
pub fn columns(mut self, columns: usize) -> Self {
self.columns_mode = ColumnsMode::Fixed(columns.max(1));
self
}
#[inline]
pub fn columns_auto(mut self, min_col_width: f32) -> Self {
self.columns_mode = ColumnsMode::Auto {
min_col_width: min_col_width.max(240.0),
};
self
}
#[inline]
pub fn pane_min_height(mut self, h: f32) -> Self {
self.pane_min_height = h.max(80.0);
self
}
#[inline]
pub fn scrollback_cap(mut self, n: usize) -> Self {
self.scrollback_cap = n.max(1);
self
}
#[inline]
pub fn history_cap(mut self, n: usize) -> Self {
self.history_cap = n.max(1);
if self.history.len() > self.history_cap {
let drop = self.history.len() - self.history_cap;
self.history.drain(0..drop);
}
self
}
#[inline]
pub fn auto_focus(mut self, enabled: bool) -> Self {
self.auto_focus = enabled;
self
}
pub fn add_pane(&mut self, pane: TerminalPane) {
if self.focused_id.is_none() {
self.focused_id = Some(pane.id.clone());
}
if pane.status == TerminalStatus::Connected && self.panes.is_empty() {
self.broadcast.insert(pane.id.clone());
}
self.panes.push(pane);
}
pub fn remove_pane(&mut self, id: &str) {
self.panes.retain(|p| p.id != id);
self.broadcast.remove(id);
if let Some(stash) = self.stashed.as_mut() {
stash.remove(id);
}
if self.focused_id.as_deref() == Some(id) {
self.focused_id = self.panes.first().map(|p| p.id.clone());
}
}
pub fn pane(&self, id: &str) -> Option<&TerminalPane> {
self.panes.iter().find(|p| p.id == id)
}
pub fn pane_mut(&mut self, id: &str) -> Option<&mut TerminalPane> {
self.panes.iter_mut().find(|p| p.id == id)
}
pub fn panes(&self) -> &[TerminalPane] {
&self.panes
}
pub fn push_line(&mut self, id: &str, line: TerminalLine) {
let cap = self.scrollback_cap;
if let Some(p) = self.panes.iter_mut().find(|p| p.id == id) {
p.lines.push(line);
if p.lines.len() > cap {
let drop = p.lines.len() - cap;
p.lines.drain(0..drop);
}
}
}
pub fn set_status(&mut self, id: &str, status: TerminalStatus) {
if let Some(p) = self.pane_mut(id) {
p.status = status;
}
if status != TerminalStatus::Connected {
self.broadcast.remove(id);
}
}
pub fn focused(&self) -> Option<&str> {
self.focused_id.as_deref()
}
pub fn set_focused(&mut self, id: Option<String>) {
self.focused_id = id;
}
pub fn broadcast(&self) -> &HashSet<String> {
&self.broadcast
}
pub fn set_broadcast(&mut self, set: HashSet<String>) {
self.broadcast = set;
self.stashed = None;
}
pub fn is_collapsed(&self, id: &str) -> bool {
self.collapsed.contains(id)
}
pub fn set_collapsed(&mut self, id: &str, collapsed: bool) {
if collapsed {
self.collapsed.insert(id.to_string());
} else {
self.collapsed.remove(id);
}
}
pub fn toggle_collapsed(&mut self, id: &str) {
if self.collapsed.contains(id) {
self.collapsed.remove(id);
} else {
self.collapsed.insert(id.to_string());
}
}
pub fn collapse_all(&mut self) {
for p in &self.panes {
self.collapsed.insert(p.id.clone());
}
}
pub fn expand_all(&mut self) {
self.collapsed.clear();
}
pub fn toggle_broadcast(&mut self, id: &str) {
if self
.pane(id)
.is_some_and(|p| p.status == TerminalStatus::Connected)
{
self.stashed = None;
if self.broadcast.contains(id) {
self.broadcast.remove(id);
} else {
self.broadcast.insert(id.to_string());
}
}
}
pub fn solo(&mut self, id: &str) {
if !self
.panes
.iter()
.any(|p| p.id == id && p.status == TerminalStatus::Connected)
{
return;
}
let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(id);
if is_solo {
self.restore_or_fallback();
} else {
self.stashed = Some(self.broadcast.clone());
self.broadcast.clear();
self.broadcast.insert(id.to_string());
}
self.focused_id = Some(id.to_string());
}
pub fn solo_focused(&mut self) {
if let Some(fid) = self.focused_id.clone() {
self.solo(&fid);
}
}
pub fn broadcast_all(&mut self) {
let connected: Vec<String> = self
.panes
.iter()
.filter(|p| p.status == TerminalStatus::Connected)
.map(|p| p.id.clone())
.collect();
let all_on =
!connected.is_empty() && connected.iter().all(|id| self.broadcast.contains(id));
self.stashed = None;
if all_on {
self.broadcast.clear();
} else {
self.broadcast = connected.into_iter().collect();
}
}
pub fn invert_broadcast(&mut self) {
self.stashed = None;
let mut next = HashSet::new();
for p in &self.panes {
if p.status != TerminalStatus::Connected {
continue;
}
if !self.broadcast.contains(&p.id) {
next.insert(p.id.clone());
}
}
self.broadcast = next;
}
pub fn pending(&self) -> &str {
&self.pending
}
pub fn clear_pending(&mut self) {
self.pending.clear();
self.pending_cursor = 0;
}
pub fn take_events(&mut self) -> Vec<TerminalEvent> {
std::mem::take(&mut self.events)
}
pub fn show(&mut self, ui: &mut Ui) -> Response {
let theme = Theme::current(ui.ctx());
let focus_id = self.id_salt;
let inner = ui
.vertical(|ui| {
self.ui_gridbar(ui, &theme);
ui.add_space(0.0);
self.ui_grid(ui, &theme);
})
.response;
let bg = ui.interact(inner.rect, focus_id, Sense::focusable_noninteractive());
if self.auto_focus {
let someone_else_has_focus = ui
.ctx()
.memory(|m| m.focused().is_some_and(|f| f != focus_id));
if !someone_else_has_focus {
ui.ctx().memory_mut(|m| m.request_focus(focus_id));
}
}
if ui.ctx().memory(|m| m.has_focus(focus_id)) {
ui.ctx().memory_mut(|m| {
m.set_focus_lock_filter(
focus_id,
egui::EventFilter {
tab: false,
horizontal_arrows: true,
vertical_arrows: true,
escape: false,
},
);
});
self.handle_keys(ui);
}
bg.widget_info(|| {
WidgetInfo::labeled(
WidgetType::Other,
true,
format!(
"Multi-terminal, {} pane{}, {} receiving",
self.panes.len(),
if self.panes.len() == 1 { "" } else { "s" },
self.target_ids().len()
),
)
});
bg
}
fn restore_or_fallback(&mut self) {
if let Some(stash) = self.stashed.take() {
self.broadcast = stash
.into_iter()
.filter(|id| {
self.panes
.iter()
.any(|p| p.id == *id && p.status == TerminalStatus::Connected)
})
.collect();
} else {
self.broadcast.clear();
}
}
fn target_ids(&self) -> Vec<String> {
self.panes
.iter()
.filter(|p| self.broadcast.contains(&p.id) && p.status == TerminalStatus::Connected)
.map(|p| p.id.clone())
.collect()
}
fn connected_count(&self) -> usize {
self.panes
.iter()
.filter(|p| p.status == TerminalStatus::Connected)
.count()
}
fn clear_targets(&mut self) {
let targets = self.target_ids();
for id in targets {
if let Some(pane) = self.panes.iter_mut().find(|p| p.id == id) {
pane.lines.clear();
}
}
}
fn run_pending(&mut self) {
let cmd = self.pending.clone();
if self.send_command(&cmd) {
self.clear_pending();
self.history_cursor = None;
}
}
pub fn send_command(&mut self, cmd: &str) -> bool {
let cmd = cmd.trim().to_string();
if cmd.is_empty() {
return false;
}
let targets = self.target_ids();
if targets.is_empty() {
return false;
}
let cap = self.scrollback_cap;
for id in &targets {
if let Some(pane) = self.panes.iter_mut().find(|p| p.id == *id) {
let line = pane.command_line(&cmd);
pane.lines.push(line);
if pane.lines.len() > cap {
let drop = pane.lines.len() - cap;
pane.lines.drain(0..drop);
}
}
}
if self.history.last().map(String::as_str) != Some(cmd.as_str()) {
self.history.push(cmd.clone());
if self.history.len() > self.history_cap {
let drop = self.history.len() - self.history_cap;
self.history.drain(0..drop);
}
}
self.events.push(TerminalEvent::Command {
targets,
command: cmd,
});
true
}
fn pending_set(&mut self, text: String) {
self.pending = text;
self.pending_cursor = self.pending.len();
}
fn pending_insert(&mut self, s: &str) {
self.pending.insert_str(self.pending_cursor, s);
self.pending_cursor += s.len();
}
fn pending_backspace(&mut self) {
if self.pending_cursor == 0 {
return;
}
let prev = self.pending_prev_boundary(self.pending_cursor);
self.pending.replace_range(prev..self.pending_cursor, "");
self.pending_cursor = prev;
}
fn pending_delete(&mut self) {
if self.pending_cursor >= self.pending.len() {
return;
}
let next = self.pending_next_boundary(self.pending_cursor);
self.pending.replace_range(self.pending_cursor..next, "");
}
fn pending_cursor_left(&mut self) {
self.pending_cursor = self.pending_prev_boundary(self.pending_cursor);
}
fn pending_cursor_right(&mut self) {
self.pending_cursor = self.pending_next_boundary(self.pending_cursor);
}
fn pending_cursor_home(&mut self) {
self.pending_cursor = 0;
}
fn pending_cursor_end(&mut self) {
self.pending_cursor = self.pending.len();
}
fn pending_prev_boundary(&self, idx: usize) -> usize {
if idx == 0 {
return 0;
}
let mut i = idx - 1;
while i > 0 && !self.pending.is_char_boundary(i) {
i -= 1;
}
i
}
fn pending_next_boundary(&self, idx: usize) -> usize {
let len = self.pending.len();
if idx >= len {
return len;
}
let mut i = idx + 1;
while i < len && !self.pending.is_char_boundary(i) {
i += 1;
}
i
}
fn step_history(&mut self, delta: isize) {
if self.history.is_empty() {
return;
}
let last = self.history.len() - 1;
let next = match self.history_cursor {
None => {
if delta < 0 {
Some(last)
} else {
return;
}
}
Some(i) => {
let i = i as isize + delta;
if i < 0 {
Some(0)
} else if i as usize > last {
None
} else {
Some(i as usize)
}
}
};
match next {
Some(i) => {
self.pending_set(self.history[i].clone());
self.history_cursor = Some(i);
}
None => {
self.clear_pending();
self.history_cursor = None;
}
}
}
fn handle_keys(&mut self, ui: &mut Ui) {
let events: Vec<Event> = ui.ctx().input(|i| i.events.clone());
for event in events {
match event {
Event::Key {
key,
pressed: true,
modifiers,
..
} => {
if modifiers.matches_exact(Modifiers::CTRL) {
match key {
Key::C => {
self.clear_pending();
self.history_cursor = None;
continue;
}
Key::E => {
self.pending_cursor_end();
continue;
}
_ => {}
}
}
if modifiers.matches_exact(Modifiers::COMMAND)
|| modifiers.matches_exact(Modifiers::CTRL)
{
match key {
Key::A => self.broadcast_all(),
Key::D => self.solo_focused(),
Key::L | Key::K => self.clear_targets(),
_ => {}
}
continue;
}
if modifiers.any() {
continue;
}
match key {
Key::Enter => self.run_pending(),
Key::Escape => {
self.clear_pending();
self.history_cursor = None;
}
Key::Backspace => self.pending_backspace(),
Key::Delete => self.pending_delete(),
Key::ArrowLeft => self.pending_cursor_left(),
Key::ArrowRight => self.pending_cursor_right(),
Key::Home => self.pending_cursor_home(),
Key::End => self.pending_cursor_end(),
Key::ArrowUp => self.step_history(-1),
Key::ArrowDown => self.step_history(1),
_ => {}
}
}
Event::Text(text) => {
let cleaned: String = text.chars().filter(|c| !c.is_control()).collect();
if !cleaned.is_empty() {
self.pending_insert(&cleaned);
}
}
Event::Paste(text) => {
let cleaned: String = text.chars().filter(|c| !c.is_control()).collect();
if !cleaned.is_empty() {
self.pending_insert(&cleaned);
}
}
_ => {}
}
}
}
fn ui_gridbar(&mut self, ui: &mut Ui, theme: &Theme) {
let palette = &theme.palette;
let typo = &theme.typography;
let connected = self.connected_count();
let targets = self.target_ids();
let targets_len = targets.len();
let height = 36.0;
let (rect, _resp) =
ui.allocate_exact_size(Vec2::new(ui.available_width(), height), Sense::hover());
let painter = ui.painter_at(rect);
painter.rect(
rect,
CornerRadius {
nw: theme.card_radius as u8,
ne: theme.card_radius as u8,
sw: 0,
se: 0,
},
palette.card,
Stroke::new(1.0, palette.border),
StrokeKind::Inside,
);
if connected > 0 {
let frac = (targets_len as f32 / connected as f32).clamp(0.0, 1.0);
let bar_top = rect.bottom() - 1.5;
let bar_rect = Rect::from_min_max(
Pos2::new(rect.left(), bar_top),
Pos2::new(rect.left() + rect.width() * frac, rect.bottom()),
);
painter.rect_filled(bar_rect, CornerRadius::ZERO, palette.sky);
}
let (mode_label, mode_style) = self.derive_mode(targets_len, connected);
let mut cursor_x = rect.left() + 14.0;
let y_mid = rect.center().y;
cursor_x += self.paint_mode_pill(
&painter,
Pos2::new(cursor_x, y_mid),
mode_label,
mode_style,
palette,
typo,
);
cursor_x += 10.0;
let summary = self.target_summary(&targets, targets_len, connected);
let summary_color = if targets_len == 0 {
palette.warning
} else {
palette.text_muted
};
let right_reserve = 280.0;
let max_text_right = (rect.right() - right_reserve).max(cursor_x + 40.0);
let summary_job = summary_layout(
&summary,
palette,
typo.label,
summary_color,
max_text_right - cursor_x,
);
let galley = painter.layout_job(summary_job);
painter.galley(
Pos2::new(cursor_x, y_mid - galley.size().y * 0.5),
galley,
palette.text_muted,
);
let mut x = rect.right() - 10.0;
let all_on = connected > 0 && targets_len == connected;
let all_w = qa_button(
ui,
rect,
&mut x,
self.id_salt.with("qa-all"),
"All on",
Some("\u{2318}A"),
all_on,
theme,
);
if all_w.clicked {
self.broadcast_all();
ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
}
}
fn target_summary(&self, targets: &[String], n: usize, connected: usize) -> String {
if n == 0 {
return "No reachable terminals".into();
}
let phrase = if n == 1 {
"Sending to"
} else if n == connected {
"Broadcasting to ALL"
} else {
"Broadcasting to"
};
let hosts: Vec<&str> = targets
.iter()
.filter_map(|id| self.pane(id).map(|p| p.host.as_str()))
.collect();
let shown = if hosts.len() <= 3 {
hosts.join(", ")
} else {
format!("{}, +{} more", hosts[..2].join(", "), hosts.len() - 2)
};
format!("{phrase} {n} \u{00b7} {shown}")
}
fn paint_mode_pill(
&self,
painter: &egui::Painter,
left_center: Pos2,
label: &str,
style: ModePillStyle,
palette: &Palette,
typo: &Typography,
) -> f32 {
let text_color = match style {
ModePillStyle::Single => palette.text_muted,
ModePillStyle::Selected => palette.sky,
ModePillStyle::All => Color32::from_rgb(0x0f, 0x17, 0x2a),
};
let (fill, border) = match style {
ModePillStyle::Single => (palette.input_bg, palette.border),
ModePillStyle::Selected => (with_alpha(palette.sky, 22), with_alpha(palette.sky, 90)),
ModePillStyle::All => (palette.sky, palette.sky),
};
let galley = painter.layout_no_wrap(
label.to_string(),
FontId::new(typo.small - 1.5, FontFamily::Proportional),
text_color,
);
let pad_x = 7.0;
let pill_h = galley.size().y + 4.0;
let pill_w = galley.size().x + pad_x * 2.0;
let pill_rect = Rect::from_center_size(
Pos2::new(left_center.x + pill_w * 0.5, left_center.y),
Vec2::new(pill_w, pill_h),
);
painter.rect(
pill_rect,
CornerRadius::same((pill_h * 0.5) as u8),
fill,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
painter.galley(
Pos2::new(
pill_rect.left() + pad_x,
pill_rect.center().y - galley.size().y * 0.5,
),
galley,
text_color,
);
pill_w
}
fn derive_mode(&self, targets: usize, connected: usize) -> (&'static str, ModePillStyle) {
if targets == 0 {
("NO TARGET", ModePillStyle::Single)
} else if targets == 1 {
("SINGLE", ModePillStyle::Single)
} else if targets == connected {
("ALL", ModePillStyle::All)
} else {
("SELECTED", ModePillStyle::Selected)
}
}
fn ui_grid(&mut self, ui: &mut Ui, theme: &Theme) {
let palette = &theme.palette;
let full_w = ui.available_width();
ui.spacing_mut().item_spacing.y = 0.0;
let inner_pad = 1.0;
let gap = 1.0;
let inner_w_for_cols = (full_w - inner_pad * 2.0).max(0.0);
let max_cols_from_width = |min_col_width: f32| -> usize {
((inner_w_for_cols + gap) / (min_col_width + gap))
.floor()
.max(1.0) as usize
};
let pane_count = self.panes.len().max(1);
let cols_raw = match self.columns_mode {
ColumnsMode::Fixed(n) => n,
ColumnsMode::Auto { min_col_width } => {
let max_cols = max_cols_from_width(min_col_width).min(pane_count);
let rows = pane_count.div_ceil(max_cols);
pane_count.div_ceil(rows)
}
};
let cols = cols_raw.max(1).min(pane_count);
let n_rows = self.panes.len().div_ceil(cols);
let header_only_h = PANE_HEADER_HEIGHT;
let row_heights: Vec<f32> = (0..n_rows)
.map(|row| {
let any_expanded = (0..cols).any(|col| {
let idx = row * cols + col;
idx < self.panes.len() && !self.collapsed.contains(&self.panes[idx].id)
});
if any_expanded {
self.pane_min_height
} else {
header_only_h
}
})
.collect();
let total_h = if self.panes.is_empty() {
60.0
} else {
inner_pad * 2.0
+ row_heights.iter().sum::<f32>()
+ (n_rows.saturating_sub(1)) as f32 * gap
};
let (outer_rect, _resp) =
ui.allocate_exact_size(Vec2::new(full_w, total_h), Sense::hover());
ui.painter().rect(
outer_rect,
CornerRadius {
nw: 0,
ne: 0,
sw: theme.card_radius as u8,
se: theme.card_radius as u8,
},
palette.border,
Stroke::NONE,
StrokeKind::Inside,
);
if self.panes.is_empty() {
ui.painter().rect(
outer_rect,
CornerRadius {
nw: 0,
ne: 0,
sw: theme.card_radius as u8,
se: theme.card_radius as u8,
},
palette.card,
Stroke::new(1.0, palette.border),
StrokeKind::Inside,
);
ui.painter().text(
outer_rect.center(),
Align2::CENTER_CENTER,
"No terminals",
FontId::proportional(theme.typography.body),
palette.text_faint,
);
return;
}
let inner = outer_rect.shrink(inner_pad);
let cell_w_for = |panes_in_row: usize| -> f32 {
let n = panes_in_row.max(1) as f32;
(inner.width() - gap * (n - 1.0)) / n
};
let mut intent_focus: Option<String> = None;
let mut intent_toggle: Option<String> = None;
let mut intent_solo: Option<String> = None;
let mut intent_collapse: Option<String> = None;
let mut y_cursor = inner.top();
let mut row_top_for = vec![0.0_f32; n_rows];
for (row, h) in row_heights.iter().enumerate() {
row_top_for[row] = y_cursor;
y_cursor += h + gap;
}
let pane_corner = (theme.card_radius - inner_pad).max(0.0) as u8;
let last_idx = self.panes.len() - 1;
let last_row = n_rows - 1;
let panes_in_last_row = self.panes.len() - last_row * cols;
for (idx, pane) in self.panes.iter().enumerate() {
let row = idx / cols;
let col = idx % cols;
let row_pane_count = if row == last_row {
panes_in_last_row
} else {
cols
};
let row_cell_w = cell_w_for(row_pane_count);
let cell_top = row_top_for[row];
let cell_left = inner.left() + col as f32 * (row_cell_w + gap);
let is_collapsed = self.collapsed.contains(&pane.id);
let cell_h = if is_collapsed {
header_only_h
} else {
row_heights[row]
};
let cell_rect = Rect::from_min_size(
Pos2::new(cell_left, cell_top),
Vec2::new(row_cell_w, cell_h),
);
let is_focused = self.focused_id.as_deref() == Some(pane.id.as_str());
let is_receiving =
self.broadcast.contains(&pane.id) && pane.status == TerminalStatus::Connected;
let is_solo = self.broadcast.len() == 1 && self.broadcast.contains(&pane.id);
let corner_radius = CornerRadius {
nw: 0,
ne: 0,
sw: if row == last_row && col == 0 {
pane_corner
} else {
0
},
se: if idx == last_idx { pane_corner } else { 0 },
};
let ctx = PaneCtx {
rect: cell_rect,
pane,
is_focused,
is_receiving,
is_solo,
is_collapsed,
corner_radius,
pending: if is_receiving { &self.pending } else { "" },
pending_cursor: if is_receiving { self.pending_cursor } else { 0 },
theme,
id_salt: self.id_salt.with(("pane", idx)),
};
let actions = draw_pane(ui, &ctx);
if actions.header_clicked || actions.body_clicked {
intent_focus = Some(pane.id.clone());
}
if actions.toggle_clicked {
intent_toggle = Some(pane.id.clone());
}
if actions.solo_clicked {
intent_solo = Some(pane.id.clone());
}
if actions.collapse_clicked || actions.header_clicked {
intent_collapse = Some(pane.id.clone());
}
}
if let Some(id) = intent_focus {
self.focused_id = Some(id);
ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
}
if let Some(id) = intent_toggle {
self.toggle_broadcast(&id);
ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
}
if let Some(id) = intent_solo {
self.solo(&id);
ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
}
if let Some(id) = intent_collapse {
self.toggle_collapsed(&id);
ui.ctx().memory_mut(|m| m.request_focus(self.id_salt));
}
}
}
const PANE_HEADER_HEIGHT: f32 = 34.0;
struct PaneCtx<'a> {
rect: Rect,
pane: &'a TerminalPane,
is_focused: bool,
is_receiving: bool,
is_solo: bool,
is_collapsed: bool,
corner_radius: CornerRadius,
pending: &'a str,
pending_cursor: usize,
theme: &'a Theme,
id_salt: Id,
}
struct PaneActions {
header_clicked: bool,
body_clicked: bool,
toggle_clicked: bool,
solo_clicked: bool,
collapse_clicked: bool,
}
fn draw_pane(ui: &mut Ui, ctx: &PaneCtx<'_>) -> PaneActions {
let palette = &ctx.theme.palette;
let p = ctx.rect;
let stroke = if ctx.is_focused {
Stroke::new(1.5, palette.sky)
} else if ctx.is_receiving {
Stroke::new(1.0, with_alpha(palette.sky, 115))
} else {
Stroke::NONE
};
ui.painter().rect(
p,
ctx.corner_radius,
palette.card,
stroke,
StrokeKind::Inside,
);
let header_rect = Rect::from_min_size(p.min, Vec2::new(p.width(), PANE_HEADER_HEIGHT));
let (header_clicked, toggle_clicked, solo_clicked, collapse_clicked) =
draw_pane_header(ui, header_rect, ctx);
let body_clicked = if ctx.is_collapsed {
false
} else {
let body_rect = Rect::from_min_max(Pos2::new(p.left(), header_rect.bottom()), p.max);
draw_pane_body(ui, body_rect, ctx)
};
PaneActions {
header_clicked,
body_clicked,
toggle_clicked,
solo_clicked,
collapse_clicked,
}
}
fn draw_pane_header(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> (bool, bool, bool, bool) {
let palette = &ctx.theme.palette;
let typo = &ctx.theme.typography;
if !ctx.is_collapsed {
ui.painter().line_segment(
[
Pos2::new(rect.left() + 1.0, rect.bottom() - 0.5),
Pos2::new(rect.right() - 1.0, rect.bottom() - 0.5),
],
Stroke::new(1.0, palette.border),
);
}
let header_resp = ui.interact(rect, ctx.id_salt.with("header"), Sense::click());
let edge_pad = 6.0;
let (collapse_clicked, chev_w) = draw_chevron_button(ui, ctx, rect, edge_pad);
let pad_x = 13.0;
let ind_size = 10.0;
let ind_center = Pos2::new(rect.right() - pad_x - ind_size * 0.5, rect.center().y);
paint_status_indicator(ui.painter(), ind_center, ctx.pane.status, palette, ind_size);
let bc_rect_right = ind_center.x - ind_size * 0.5 - 8.0;
let (toggle_clicked, bc_w) = draw_broadcast_pill(ui, ctx, bc_rect_right, rect.center().y);
let solo_right = bc_rect_right - bc_w - 6.0;
let (solo_clicked, solo_w) = draw_solo_button(ui, ctx, solo_right, rect.center().y);
let solo_left = solo_right - solo_w;
let host_x = rect.left() + edge_pad + chev_w + 6.0;
let host_max_w = (solo_left - host_x - 6.0).max(0.0);
let mut job = LayoutJob::default();
job.wrap.max_width = host_max_w;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
job.wrap.overflow_character = Some('\u{2026}');
job.append(
&ctx.pane.host,
0.0,
TextFormat {
font_id: FontId::monospace(typo.small + 0.5),
color: palette.text,
..Default::default()
},
);
job.append(
&format!("@{}", ctx.pane.user),
0.0,
TextFormat {
font_id: FontId::monospace(typo.small + 0.5),
color: palette.text_faint,
..Default::default()
},
);
let galley = ui.painter().layout_job(job);
ui.painter().galley(
Pos2::new(host_x, rect.center().y - galley.size().y * 0.5),
galley,
palette.text,
);
(
header_resp.clicked(),
toggle_clicked,
solo_clicked,
collapse_clicked,
)
}
fn draw_chevron_button(ui: &mut Ui, ctx: &PaneCtx<'_>, header: Rect, edge_pad: f32) -> (bool, f32) {
let palette = &ctx.theme.palette;
let size = 18.0;
let rect = Rect::from_center_size(
Pos2::new(header.left() + edge_pad + size * 0.5, header.center().y),
Vec2::splat(size),
);
let resp = ui.interact(rect, ctx.id_salt.with("chev"), Sense::click());
let color = if resp.hovered() {
palette.text
} else {
palette.text_muted
};
let c = rect.center();
let h = 3.5; let pts = if ctx.is_collapsed {
vec![
Pos2::new(c.x - h * 0.7, c.y - h),
Pos2::new(c.x - h * 0.7, c.y + h),
Pos2::new(c.x + h, c.y),
]
} else {
vec![
Pos2::new(c.x - h, c.y - h * 0.7),
Pos2::new(c.x + h, c.y - h * 0.7),
Pos2::new(c.x, c.y + h),
]
};
ui.painter()
.add(egui::Shape::convex_polygon(pts, color, Stroke::NONE));
(resp.clicked(), size)
}
fn paint_status_indicator(
painter: &egui::Painter,
center: Pos2,
status: TerminalStatus,
palette: &Palette,
size: f32,
) {
let r = size * 0.5;
match status {
TerminalStatus::Connected => {
painter.circle_filled(center, r + 1.5, with_alpha(palette.success, 70));
painter.circle_filled(center, r, palette.success);
}
TerminalStatus::Reconnecting => {
painter.circle_stroke(center, r - 0.5, Stroke::new(1.8, palette.warning));
}
TerminalStatus::Offline => {
painter.circle_stroke(center, r - 0.5, Stroke::new(1.0, palette.danger));
let bar_w = size * 0.7;
let bar_h = 2.0;
let bar = Rect::from_center_size(center, Vec2::new(bar_w, bar_h));
painter.rect_filled(bar, CornerRadius::same(1), palette.danger);
}
}
}
fn draw_broadcast_pill(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
let palette = &ctx.theme.palette;
let dim = ctx.pane.status != TerminalStatus::Connected;
let pill_w = 34.0;
let pill_h = 22.0;
let rect = Rect::from_min_size(
Pos2::new(right_edge - pill_w, y_mid - pill_h * 0.5),
Vec2::new(pill_w, pill_h),
);
let resp = ui.interact(rect, ctx.id_salt.with("bcast"), Sense::click());
let hovered = resp.hovered() && !dim;
let (fill, border, icon_color) = if ctx.is_receiving {
let fill = if hovered {
palette.depth_tint(palette.sky, 0.12)
} else {
palette.sky
};
(fill, palette.sky, Color32::from_rgb(0x0f, 0x17, 0x2a))
} else if hovered {
(
with_alpha(palette.sky, 26),
with_alpha(palette.sky, 130),
palette.sky,
)
} else {
(Color32::TRANSPARENT, palette.border, palette.text_faint)
};
ui.painter().rect(
rect,
CornerRadius::same((pill_h * 0.5) as u8),
fill,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
let center = rect.center();
if ctx.is_receiving {
let t = ui.input(|i| i.time);
let phase = (t.rem_euclid(1.2) / 1.2) as f32;
let halo_r = 2.0 + phase.min(1.0) * 4.5;
let halo_a = (70.0 * (1.0 - phase)).clamp(0.0, 255.0) as u8;
ui.painter()
.circle_filled(center, halo_r, with_alpha(icon_color, halo_a));
}
paint_broadcast_glyph(ui.painter(), center, icon_color);
(if dim { false } else { resp.clicked() }, pill_w)
}
fn paint_broadcast_glyph(painter: &egui::Painter, center: Pos2, color: Color32) {
painter.circle_filled(center, 1.8, color);
let stroke = Stroke::new(1.2, color);
use std::f32::consts::PI;
paint_arc(painter, center, 4.5, -0.45, 0.45, stroke);
paint_arc(painter, center, 4.5, PI - 0.45, PI + 0.45, stroke);
paint_arc(painter, center, 7.5, -0.32, 0.32, stroke);
paint_arc(painter, center, 7.5, PI - 0.32, PI + 0.32, stroke);
}
fn paint_arc(
painter: &egui::Painter,
center: Pos2,
radius: f32,
start: f32,
end: f32,
stroke: Stroke,
) {
const STEPS: usize = 8;
let mut pts = Vec::with_capacity(STEPS + 1);
for i in 0..=STEPS {
let t = i as f32 / STEPS as f32;
let a = start + (end - start) * t;
pts.push(Pos2::new(
center.x + radius * a.cos(),
center.y + radius * a.sin(),
));
}
painter.add(egui::Shape::line(pts, stroke));
}
fn draw_solo_button(ui: &mut Ui, ctx: &PaneCtx<'_>, right_edge: f32, y_mid: f32) -> (bool, f32) {
let palette = &ctx.theme.palette;
let dim = ctx.pane.status != TerminalStatus::Connected;
let size = 22.0;
let rect = Rect::from_min_size(
Pos2::new(right_edge - size, y_mid - size * 0.5),
Vec2::splat(size),
);
let resp = ui.interact(rect, ctx.id_salt.with("solo"), Sense::click());
let hovered = resp.hovered() && !dim;
let (fill, border, icon_color) = if ctx.is_solo {
(with_alpha(palette.sky, 28), palette.sky, palette.sky)
} else if hovered {
(Color32::TRANSPARENT, palette.text_muted, palette.text)
} else {
(Color32::TRANSPARENT, palette.border, palette.text_faint)
};
ui.painter().rect(
rect,
CornerRadius::same((size * 0.5) as u8),
fill,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
paint_solo_icon(ui.painter(), rect.center(), icon_color);
(if dim { false } else { resp.clicked() }, size)
}
fn paint_solo_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
let pad = 1.0;
let cell = 5.5;
let cells = [
(-cell - pad, -cell - pad, true),
(pad, -cell - pad, false),
(-cell - pad, pad, false),
(pad, pad, false),
];
for (dx, dy, filled) in cells {
let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(cell));
if filled {
painter.rect_filled(r, CornerRadius::same(1), color);
} else {
painter.rect_stroke(
r,
CornerRadius::same(1),
Stroke::new(1.2, color),
StrokeKind::Inside,
);
}
}
}
fn draw_pane_body(ui: &mut Ui, rect: Rect, ctx: &PaneCtx<'_>) -> bool {
let palette = &ctx.theme.palette;
let typo = &ctx.theme.typography;
let term_bg = palette.depth_tint(palette.input_bg, 0.015);
ui.painter().rect_filled(
rect.shrink2(Vec2::new(1.0, 1.0)),
CornerRadius {
nw: 0,
ne: 0,
sw: (ctx.theme.control_radius + 1.0) as u8,
se: (ctx.theme.control_radius + 1.0) as u8,
},
term_bg,
);
let body_resp = ui.interact(rect, ctx.id_salt.with("body"), Sense::click());
let mut child = ui.new_child(
egui::UiBuilder::new()
.max_rect(rect.shrink(8.0))
.layout(egui::Layout::top_down(egui::Align::Min)),
);
child.spacing_mut().item_spacing.y = 2.0;
let mut label_interacted = false;
egui::ScrollArea::vertical()
.id_salt(ctx.id_salt.with("scroll"))
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(&mut child, |ui| {
for line in &ctx.pane.lines {
if paint_line(ui, line, palette, typo) {
label_interacted = true;
}
}
if paint_live_prompt(ui, ctx, palette, typo) {
label_interacted = true;
}
});
body_resp.clicked() || label_interacted
}
fn paint_line(ui: &mut Ui, line: &TerminalLine, palette: &Palette, typo: &Typography) -> bool {
let size = typo.small + 0.5;
let font = FontId::monospace(size);
let wrap_width = ui.available_width();
match &line.kind {
LineKind::Command {
user,
host,
cwd,
cmd,
} => {
let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width;
job.wrap.break_anywhere = true;
job.append(
&format!("{user}@{host}"),
0.0,
TextFormat {
font_id: font.clone(),
color: palette.success,
..Default::default()
},
);
job.append(
":",
0.0,
TextFormat {
font_id: font.clone(),
color: palette.text_muted,
..Default::default()
},
);
job.append(
cwd,
0.0,
TextFormat {
font_id: font.clone(),
color: palette.purple,
..Default::default()
},
);
job.append(
"$ ",
0.0,
TextFormat {
font_id: font.clone(),
color: palette.text_muted,
..Default::default()
},
);
job.append(
cmd,
0.0,
TextFormat {
font_id: font,
color: palette.text,
..Default::default()
},
);
let resp = ui.add(egui::Label::new(job).selectable(true));
resp.clicked() || resp.dragged()
}
other => {
let color = color_for_kind(other, palette);
let italic = matches!(other, LineKind::Info);
let rich = egui::RichText::new(&line.text).font(font).color(color);
let rich = if italic { rich.italics() } else { rich };
let resp = ui.add(egui::Label::new(rich).wrap().selectable(true));
resp.clicked() || resp.dragged()
}
}
}
fn paint_live_prompt(ui: &mut Ui, ctx: &PaneCtx<'_>, palette: &Palette, typo: &Typography) -> bool {
let size = typo.small + 0.5;
let font = FontId::monospace(size);
let pane = ctx.pane;
let mut job = LayoutJob::default();
job.wrap.max_width = (ui.available_width() - 10.0).max(40.0);
job.wrap.break_anywhere = true;
job.append(
&format!("{}@{}", pane.user, pane.host),
0.0,
TextFormat {
font_id: font.clone(),
color: palette.success,
..Default::default()
},
);
job.append(
":",
0.0,
TextFormat {
font_id: font.clone(),
color: palette.text_muted,
..Default::default()
},
);
job.append(
&pane.cwd,
0.0,
TextFormat {
font_id: font.clone(),
color: palette.purple,
..Default::default()
},
);
job.append(
"$ ",
0.0,
TextFormat {
font_id: font.clone(),
color: palette.text_muted,
..Default::default()
},
);
if !ctx.pending.is_empty() {
job.append(
ctx.pending,
0.0,
TextFormat {
font_id: font.clone(),
color: palette.sky,
..Default::default()
},
);
}
let galley = ui.painter().layout_job(job);
let caret_h = size + 2.0;
let block_caret_w = 7.0;
let total_size = Vec2::new(
galley.size().x + block_caret_w + 2.0,
galley.size().y.max(caret_h),
);
let prefix_chars = pane.user.chars().count()
+ 1 + pane.host.chars().count()
+ 1 + pane.cwd.chars().count()
+ 2; let cursor_byte = ctx.pending_cursor.min(ctx.pending.len());
let pending_chars_before = ctx.pending[..cursor_byte].chars().count();
let caret_local = galley.pos_from_cursor(CCursor::new(prefix_chars + pending_chars_before));
let cursor_at_end = ctx.pending_cursor >= ctx.pending.len();
let caret_w = if cursor_at_end { block_caret_w } else { 2.0 };
let galley_size = galley.size();
let (rect, _resp) = ui.allocate_exact_size(total_size, Sense::hover());
let galley_origin = rect.min;
let label_rect = Rect::from_min_size(galley_origin, galley_size);
let resp = ui.put(label_rect, egui::Label::new(galley).selectable(true));
let row_top = galley_origin.y + caret_local.top();
let row_bottom = galley_origin.y + caret_local.bottom();
let caret_y_center = (row_top + row_bottom) * 0.5;
let caret_rect = Rect::from_min_size(
Pos2::new(
galley_origin.x + caret_local.left(),
caret_y_center - caret_h * 0.5,
),
Vec2::new(caret_w, caret_h),
);
let caret_color = if ctx.is_receiving {
palette.sky
} else {
with_alpha(palette.text_faint, 80)
};
ui.painter()
.rect_filled(caret_rect, CornerRadius::ZERO, caret_color);
resp.clicked() || resp.dragged()
}
fn color_for_kind(kind: &LineKind, palette: &Palette) -> Color32 {
match kind {
LineKind::Out => palette.text,
LineKind::Info => palette.text_faint,
LineKind::Ok => palette.success,
LineKind::Warn => palette.warning,
LineKind::Err => palette.danger,
LineKind::Dim => palette.text_muted,
LineKind::Command { .. } => palette.text,
}
}
fn summary_layout(
text: &str,
palette: &Palette,
size: f32,
color: Color32,
max_width: f32,
) -> LayoutJob {
let mut job = LayoutJob::default();
job.wrap.max_width = max_width;
job.wrap.max_rows = 1;
job.wrap.break_anywhere = true;
job.wrap.overflow_character = Some('\u{2026}');
job.append(
text,
0.0,
TextFormat {
font_id: FontId::new(size, FontFamily::Proportional),
color,
..Default::default()
},
);
let _ = palette;
job
}
struct QaResult {
clicked: bool,
}
#[allow(clippy::too_many_arguments)]
fn qa_button(
ui: &mut Ui,
bar_rect: Rect,
x_right: &mut f32,
id: Id,
label: &str,
shortcut: Option<&str>,
active: bool,
theme: &Theme,
) -> QaResult {
let palette = &theme.palette;
let typo = &theme.typography;
let font = FontId::new(typo.small, FontFamily::Proportional);
let label_galley = ui
.painter()
.layout_no_wrap(label.to_string(), font.clone(), palette.text);
let kbd_font = FontId::monospace(typo.small - 1.5);
let kbd_galley = shortcut.map(|s| {
ui.painter()
.layout_no_wrap(s.to_string(), kbd_font.clone(), palette.text_faint)
});
let icon_w = 16.0;
let pad_x = 8.0;
let label_w = label_galley.size().x;
let kbd_w = kbd_galley.as_ref().map(|g| g.size().x + 8.0).unwrap_or(0.0);
let btn_w = icon_w + 6.0 + label_w + kbd_w + pad_x * 2.0;
let btn_h = bar_rect.height() - 10.0;
let btn_rect = Rect::from_min_size(
Pos2::new(*x_right - btn_w, bar_rect.center().y - btn_h * 0.5),
Vec2::new(btn_w, btn_h),
);
*x_right = btn_rect.left() - 4.0;
let resp = ui.interact(btn_rect, id, Sense::click());
let hover = resp.hovered();
let (fg, border, fill) = if active {
(
palette.sky,
with_alpha(palette.sky, 110),
with_alpha(palette.sky, 22),
)
} else if hover {
(palette.text, palette.text_muted, Color32::TRANSPARENT)
} else {
(palette.text_muted, palette.border, Color32::TRANSPARENT)
};
ui.painter().rect(
btn_rect,
CornerRadius::same(theme.control_radius as u8),
fill,
Stroke::new(1.0, border),
StrokeKind::Inside,
);
let icon_center = Pos2::new(btn_rect.left() + pad_x + icon_w * 0.5, btn_rect.center().y);
paint_grid_icon(ui.painter(), icon_center, fg);
let label_x = btn_rect.left() + pad_x + icon_w + 6.0;
let label_galley2 = ui
.painter()
.layout_no_wrap(label.to_string(), font.clone(), fg);
ui.painter().galley(
Pos2::new(label_x, btn_rect.center().y - label_galley2.size().y * 0.5),
label_galley2,
fg,
);
if let Some(kbd) = shortcut {
let kbd_galley2 =
ui.painter()
.layout_no_wrap(kbd.to_string(), kbd_font.clone(), palette.text_faint);
let kbd_rect = Rect::from_min_size(
Pos2::new(
btn_rect.right() - pad_x - kbd_galley2.size().x - 8.0,
btn_rect.center().y - (kbd_galley2.size().y + 2.0) * 0.5,
),
Vec2::new(kbd_galley2.size().x + 8.0, kbd_galley2.size().y + 2.0),
);
ui.painter().rect(
kbd_rect,
CornerRadius::same(3),
palette.input_bg,
Stroke::new(1.0, palette.border),
StrokeKind::Inside,
);
ui.painter().galley(
Pos2::new(
kbd_rect.left() + 4.0,
kbd_rect.center().y - kbd_galley2.size().y * 0.5,
),
kbd_galley2,
palette.text_faint,
);
}
QaResult {
clicked: resp.clicked(),
}
}
fn paint_grid_icon(painter: &egui::Painter, center: Pos2, color: Color32) {
let pad = 1.0;
let size = 5.5;
for (dx, dy) in &[
(-size - pad, -size - pad),
(pad, -size - pad),
(-size - pad, pad),
(pad, pad),
] {
let r = Rect::from_min_size(Pos2::new(center.x + dx, center.y + dy), Vec2::splat(size));
painter.rect_stroke(
r,
CornerRadius::same(1),
Stroke::new(1.2, color),
StrokeKind::Inside,
);
}
}
#[derive(Clone, Copy)]
enum ModePillStyle {
Single,
Selected,
All,
}
fn with_alpha(c: Color32, a: u8) -> Color32 {
Color32::from_rgba_unmultiplied(c.r(), c.g(), c.b(), a)
}