mod glyph_renderer;
mod i18n;
mod keybindings;
mod layout;
mod selection;
use crate::config::Settings;
use crate::platform::{
center_with_compositor, clipboard_has_image, desktop_work_area_size, open_uri, play_bell,
publish_primary_selection, read_primary_selection,
};
use arreliny_terminal::{Color, CursorStyle, Rendition, Screen, TerminalSession};
use eframe::egui;
use glyph_renderer::{
FontMetrics, GlyphRenderer, GlyphRun, GlyphSpan, TerminalTextCallback, font_metrics,
};
use i18n::{Language, translate as tr};
use keybindings::{
accelerator as make_accelerator, is_modifier as modifier_key, matches as accelerator_matches,
terminal_bytes as key_bytes,
};
use layout::{
dimensions as layout_dimensions, encode_weight_sets, normalized_weights, pane_rects,
parse_weight_sets, window_size as layout_window_size,
};
use selection::{contains as selection_contains, text as selected_text, word as word_selection};
use std::collections::HashMap;
use std::env;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::{Duration, Instant};
struct Pane {
terminal: TerminalSession,
rows: usize,
columns: usize,
scroll_offset: usize,
scroll_remainder: f32,
selection: Option<Selection>,
selection_anchor: Option<((usize, usize), egui::Pos2)>,
title: String,
rect: egui::Rect,
working_directory: PathBuf,
bell_epoch: Option<Instant>,
}
#[derive(Clone, Copy)]
struct Selection {
start: (usize, usize),
end: (usize, usize),
}
#[derive(Clone, Copy)]
struct SearchMatch {
line: usize,
start: usize,
end: usize,
}
struct Arreliny {
panes: Vec<Pane>,
active: usize,
palette: [egui::Color32; 16],
foreground: egui::Color32,
background: egui::Color32,
focus: egui::Color32,
selection_foreground: egui::Color32,
selection_background: egui::Color32,
suggestion_color: egui::Color32,
pending_expansion: Option<PendingExpansion>,
settings: Settings,
show_settings: bool,
settings_tab: SettingsTab,
show_font_picker: bool,
font_search: String,
system_fonts: Vec<String>,
recording_binding: Option<Binding>,
font_size: f32,
font_metrics: FontMetrics,
cursor_epoch: Instant,
glyph_runs: Vec<GlyphRun>,
pressed_mouse: Option<u8>,
show_search: bool,
search_query: String,
search_matches: Vec<SearchMatch>,
search_current: usize,
search_dirty: bool,
search_pane: usize,
search_focus: bool,
error_message: Option<String>,
observed_window_size: Option<egui::Vec2>,
column_weights: HashMap<usize, Vec<f32>>,
row_weights: HashMap<usize, Vec<f32>>,
divider_dirty: Option<Instant>,
window_title: String,
v_pressed: bool,
last_image_paste: Option<Instant>,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Binding {
Spawn,
Close,
Left,
Right,
Up,
Down,
Equalize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum SettingsTab {
Controls,
Appearance,
Panes,
}
struct PendingExpansion {
target: egui::Vec2,
requested_at: Instant,
retry_sent: bool,
}
impl Arreliny {
fn new(settings: Settings, context: &egui::Context) -> Result<Self, String> {
let (foreground, background, palette) = theme(&settings);
let focus = parse_color(&settings.focus_color).unwrap_or_else(|| rgb(0xf0, 0xb3, 0x5a));
let selection_foreground =
parse_color(&settings.selection_foreground).unwrap_or(egui::Color32::WHITE);
let selection_background =
parse_color(&settings.selection_background).unwrap_or_else(|| rgb(0x5a, 0x78, 0xb4));
let suggestion_color =
parse_color(&settings.suggestion_color).unwrap_or_else(|| rgb(0x7f, 0x84, 0x9c));
let font_size = terminal_font(&settings.font).1 * 4.0 / 3.0;
let font_metrics = measure_font(&settings, font_size);
let pane = spawn_pane(&settings, font_metrics, None, context)?;
let error_message = settings_warning(&settings);
let column_weights = if settings.persist_dividers {
parse_weight_sets(&settings.divider_columns)
} else {
HashMap::new()
};
let row_weights = if settings.persist_dividers {
parse_weight_sets(&settings.divider_rows)
} else {
HashMap::new()
};
Ok(Self {
panes: vec![pane],
active: 0,
palette,
foreground,
background,
focus,
selection_foreground,
selection_background,
suggestion_color,
pending_expansion: None,
settings,
show_settings: false,
settings_tab: SettingsTab::Controls,
show_font_picker: false,
font_search: String::new(),
system_fonts: Vec::new(),
recording_binding: None,
font_size,
font_metrics,
cursor_epoch: Instant::now(),
glyph_runs: Vec::new(),
pressed_mouse: None,
show_search: false,
search_query: String::new(),
search_matches: Vec::new(),
search_current: 0,
search_dirty: false,
search_pane: 0,
search_focus: false,
error_message,
observed_window_size: None,
column_weights,
row_weights,
divider_dirty: None,
window_title: String::new(),
v_pressed: false,
last_image_paste: None,
})
}
fn input_active(&mut self, bytes: &[u8]) {
let pane = &mut self.panes[self.active];
pane.scroll_offset = 0;
let _ = pane.terminal.input(bytes);
}
fn process_input(&mut self, context: &egui::Context) {
if self.show_font_picker {
return;
}
let dropped_paths = context.input(|input| {
input
.raw
.dropped_files
.iter()
.filter_map(|file| file.path.as_deref())
.map(terminal_path)
.collect::<Vec<_>>()
});
if !dropped_paths.is_empty() {
self.panes[self.active].selection = None;
let text = dropped_paths.join(" ");
paste_bytes(
&mut self.panes[self.active],
text.as_bytes(),
self.settings.bracketed_paste,
);
}
let events = context.input(|input| input.events.clone());
let mut suppress_text = false;
let mut suppress_copy = false;
let mut suppress_paste = false;
for event in events {
if self.show_search {
match &event {
egui::Event::Key {
key: egui::Key::Escape,
pressed: true,
..
} => {
self.show_search = false;
continue;
}
egui::Event::Key {
key: egui::Key::Enter,
pressed: true,
modifiers,
..
} => {
self.navigate_search(!modifiers.shift);
continue;
}
egui::Event::Key { .. }
| egui::Event::Text(_)
| egui::Event::Paste(_)
| egui::Event::Copy
| egui::Event::Cut => continue,
_ => {}
}
}
match event {
egui::Event::Key {
key,
physical_key,
pressed: false,
..
} if physical_key.unwrap_or(key) == egui::Key::V => {
let had_press = std::mem::replace(&mut self.v_pressed, false);
let debounced = self
.last_image_paste
.is_some_and(|instant| instant.elapsed() < Duration::from_millis(250));
if !had_press && !debounced && clipboard_has_image() {
self.last_image_paste = Some(Instant::now());
self.cursor_epoch = Instant::now();
self.panes[self.active].selection = None;
self.input_active(b"\x16");
}
}
egui::Event::Key {
key,
physical_key,
pressed: true,
modifiers,
..
} => {
self.cursor_epoch = Instant::now();
let shortcut_key = physical_key.unwrap_or(key);
if shortcut_key == egui::Key::V {
self.v_pressed = true;
}
if self.recording_binding.is_none()
&& modifiers.command
&& shortcut_key == egui::Key::C
{
suppress_copy = true;
if self.panes[self.active].selection.is_some() {
if let Some(text) = selected_text(
&self.panes[self.active],
self.settings.trim_selection,
) {
context.copy_text(text);
}
suppress_text = true;
continue;
}
if modifiers.shift {
suppress_text = true;
continue;
}
if !modifiers.ctrl {
suppress_text = true;
continue;
}
}
if self.recording_binding.is_none()
&& modifiers.command
&& shortcut_key == egui::Key::V
{
suppress_text = true;
if clipboard_has_image() {
suppress_paste = true;
self.panes[self.active].selection = None;
self.input_active(b"\x16");
}
continue;
}
if self.recording_binding.is_some() && modifier_key(shortcut_key) {
suppress_text = true;
continue;
}
if let Some(binding) = self.recording_binding.take() {
if shortcut_key != egui::Key::Escape {
let accelerator = make_accelerator(shortcut_key, modifiers);
self.set_binding(binding, accelerator);
if let Err(error) = self.settings.save() {
self.error_message = Some(format!(
"{}: {error}",
tr(
Language::from_setting(&self.settings.language),
"Failed to save settings"
)
));
}
}
suppress_text = true;
continue;
}
if self.handle_shortcut(context, shortcut_key, modifiers) {
suppress_text = true;
continue;
}
let application_cursor = self.panes[self.active].terminal.application_cursor();
if let Some(bytes) = key_bytes(key, modifiers, application_cursor) {
let pane = &mut self.panes[self.active];
pane.selection = None;
pane.scroll_offset = 0;
let _ = pane.terminal.input(&bytes);
}
}
egui::Event::Text(text) => {
self.cursor_epoch = Instant::now();
if suppress_text {
suppress_text = false;
} else {
let alt = context.input(|input| input.modifiers.alt);
self.panes[self.active].selection = None;
if alt {
self.input_active(b"\x1b");
}
self.input_active(text.as_bytes());
}
}
egui::Event::Paste(text) => {
if suppress_paste {
suppress_paste = false;
continue;
}
if clipboard_has_image() {
self.cursor_epoch = Instant::now();
self.panes[self.active].selection = None;
self.input_active(b"\x16");
continue;
}
self.cursor_epoch = Instant::now();
let pane = &mut self.panes[self.active];
pane.selection = None;
pane.scroll_offset = 0;
paste_bytes(pane, text.as_bytes(), self.settings.bracketed_paste);
}
egui::Event::Copy => {
if suppress_copy {
suppress_copy = false;
} else if self.panes[self.active].selection.is_some() {
if let Some(text) =
selected_text(&self.panes[self.active], self.settings.trim_selection)
{
context.copy_text(text);
}
} else {
self.cursor_epoch = Instant::now();
self.input_active(b"\x03");
}
}
egui::Event::PointerButton {
pos,
button,
pressed,
modifiers,
} => {
if button == egui::PointerButton::Middle
&& pressed
&& self.paste_primary_selection(context, pos, modifiers)
{
continue;
}
if !modifiers.shift
&& self.send_mouse_button(context, pos, button, pressed, modifiers)
{
continue;
}
if button == egui::PointerButton::Primary && pressed {
self.activate_at(context, pos);
}
}
egui::Event::PointerMoved(pos) => {
self.send_mouse_motion(context, pos);
}
egui::Event::MouseWheel {
unit,
delta,
modifiers,
..
} => {
if !modifiers.shift
&& let Some(pos) = context.input(|input| input.pointer.latest_pos())
{
self.handle_mouse_wheel(context, pos, unit, delta.y, modifiers);
}
}
egui::Event::WindowFocused(focused)
if self.panes[self.active].terminal.focus_events() =>
{
let sequence = if focused { b"\x1b[I" } else { b"\x1b[O" };
let _ = self.panes[self.active].terminal.input(sequence);
}
_ => {}
}
}
}
fn activate_at(&mut self, _context: &egui::Context, position: egui::Pos2) {
if let Some(index) = self
.panes
.iter()
.position(|pane| pane.rect.contains(position))
{
self.change_active(index);
self.panes[index].selection = None;
self.panes[index].selection_anchor = None;
self.cursor_epoch = Instant::now();
}
}
fn mouse_target(
&self,
_: &egui::Context,
position: egui::Pos2,
) -> Option<(usize, usize, usize)> {
let index = self
.panes
.iter()
.position(|pane| pane.rect.contains(position))?;
let rect = self.panes[index].rect;
let cell_width = self.font_metrics.cell_width;
let cell_height = self.font_metrics.line_height;
let column = ((position.x - rect.left() - 4.0) / cell_width)
.floor()
.max(0.0) as usize;
let row = ((position.y - rect.top() - 4.0) / cell_height)
.floor()
.max(0.0) as usize;
Some((
index,
column.min(self.panes[index].columns - 1),
row.min(self.panes[index].rows - 1),
))
}
fn send_mouse_button(
&mut self,
context: &egui::Context,
position: egui::Pos2,
button: egui::PointerButton,
pressed: bool,
modifiers: egui::Modifiers,
) -> bool {
if !pressed {
self.pressed_mouse = None;
}
let Some((index, column, row)) = self.mouse_target(context, position) else {
return false;
};
if self.panes[index].terminal.mouse_tracking() == 0 {
return false;
}
let Some(button) = mouse_button_code(button) else {
return false;
};
self.change_active(index);
if pressed {
self.pressed_mouse = Some(button);
}
send_terminal_mouse(
&mut self.panes[index],
button,
column,
row,
pressed,
modifiers,
);
true
}
fn paste_primary_selection(
&mut self,
context: &egui::Context,
position: egui::Pos2,
modifiers: egui::Modifiers,
) -> bool {
let Some((index, _, _)) = self.mouse_target(context, position) else {
return false;
};
if !self.settings.middle_click_paste
|| self.panes[index].terminal.mouse_tracking() != 0 && !modifiers.shift
{
return false;
}
let Some(text) = read_primary_selection() else {
return false;
};
self.change_active(index);
self.panes[index].selection = None;
paste_bytes(&mut self.panes[index], &text, self.settings.bracketed_paste);
true
}
fn send_mouse_motion(&mut self, context: &egui::Context, position: egui::Pos2) {
let Some((index, column, row)) = self.mouse_target(context, position) else {
return;
};
let tracking = self.panes[index].terminal.mouse_tracking();
if tracking != 1003 && !(tracking == 1002 && self.pressed_mouse.is_some()) {
return;
}
let button = self.pressed_mouse.unwrap_or(3) + 32;
let modifiers = context.input(|input| input.modifiers);
send_terminal_mouse(&mut self.panes[index], button, column, row, true, modifiers);
}
fn handle_mouse_wheel(
&mut self,
context: &egui::Context,
position: egui::Pos2,
unit: egui::MouseWheelUnit,
delta: f32,
modifiers: egui::Modifiers,
) {
let Some((index, column, row)) = self.mouse_target(context, position) else {
return;
};
if delta == 0.0 {
return;
}
let pane = &mut self.panes[index];
if pane.terminal.mouse_tracking() != 0 {
let button = if delta > 0.0 { 64 } else { 65 };
send_terminal_mouse(pane, button, column, row, true, modifiers);
return;
}
let line_delta = match unit {
egui::MouseWheelUnit::Line => {
delta.signum() * delta.abs().ceil() * self.settings.scroll_lines as f32
}
egui::MouseWheelUnit::Point => {
pane.scroll_remainder += delta / self.font_metrics.line_height;
let lines = pane.scroll_remainder.trunc();
pane.scroll_remainder -= lines;
lines
}
egui::MouseWheelUnit::Page => delta.signum() * pane.terminal.screen().rows() as f32,
};
let lines = line_delta.abs() as usize;
if line_delta > 0.0 {
pane.scroll_offset =
(pane.scroll_offset + lines).min(pane.terminal.screen().history_len());
} else if line_delta < 0.0 {
pane.scroll_offset = pane.scroll_offset.saturating_sub(lines);
}
}
fn set_binding(&mut self, binding: Binding, accelerator: String) {
match binding {
Binding::Spawn => self.settings.spawn_accel = accelerator,
Binding::Close => self.settings.close_accel = accelerator,
Binding::Left => self.settings.focus_left_accel = accelerator,
Binding::Right => self.settings.focus_right_accel = accelerator,
Binding::Up => self.settings.focus_up_accel = accelerator,
Binding::Down => self.settings.focus_down_accel = accelerator,
Binding::Equalize => self.settings.equalize_panes_accel = accelerator,
}
}
fn handle_shortcut(
&mut self,
context: &egui::Context,
key: egui::Key,
modifiers: egui::Modifiers,
) -> bool {
if modifiers.command && modifiers.shift && key == egui::Key::F {
self.show_search = true;
self.search_focus = true;
self.search_dirty = true;
return true;
}
if accelerator_matches(&self.settings.spawn_accel, key, modifiers) {
self.expand(context);
return true;
}
if accelerator_matches(&self.settings.close_accel, key, modifiers) {
self.close_active(context);
return true;
}
if accelerator_matches(&self.settings.equalize_panes_accel, key, modifiers) {
self.equalize_panes();
return true;
}
let direction = [
(&self.settings.focus_left_accel, egui::Key::ArrowLeft),
(&self.settings.focus_right_accel, egui::Key::ArrowRight),
(&self.settings.focus_up_accel, egui::Key::ArrowUp),
(&self.settings.focus_down_accel, egui::Key::ArrowDown),
]
.into_iter()
.find_map(|(accelerator, direction)| {
accelerator_matches(accelerator, key, modifiers).then_some(direction)
});
if let Some(direction) = direction {
self.focus_direction(direction);
return true;
}
false
}
fn equalize_panes(&mut self) {
let (columns, rows) = layout_dimensions(self.panes.len(), &self.settings.pane_layout);
self.column_weights
.insert(columns, vec![1.0 / columns as f32; columns]);
self.row_weights.insert(rows, vec![1.0 / rows as f32; rows]);
self.divider_dirty = Some(Instant::now() - Duration::from_millis(300));
}
fn expand(&mut self, context: &egui::Context) {
if self.panes.len() >= self.settings.max_panes as usize {
return;
}
let additions = 1;
let count = self.panes.len() + additions;
let base = egui::vec2(
self.settings.base_width as f32,
self.settings.base_height as f32,
);
let size = layout_window_size(
base,
count,
self.settings.pane_gap as f32,
&self.settings.pane_layout,
);
let size = self.constrain_to_work_area(context, size);
let directory = self.panes[self.active].working_directory.clone();
let pane = match spawn_pane(&self.settings, self.font_metrics, Some(&directory), context) {
Ok(pane) => pane,
Err(error) => {
self.error_message = Some(format!(
"{}: {error}",
tr(
Language::from_setting(&self.settings.language),
"Failed to open terminal"
)
));
return;
}
};
context.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
self.center_after_resize(context, size);
self.panes.push(pane);
self.pending_expansion = Some(PendingExpansion {
target: size,
requested_at: Instant::now(),
retry_sent: false,
});
}
fn finish_expansion(&mut self, context: &egui::Context) {
let Some(pending) = &mut self.pending_expansion else {
return;
};
let current = context.input(|input| input.viewport().inner_rect.map(|rect| rect.size()));
let resized = current.is_some_and(|size| {
(size.x - pending.target.x).abs() <= 2.0 && (size.y - pending.target.y).abs() <= 2.0
});
let elapsed = pending.requested_at.elapsed();
let pointer_down = context.input(|input| input.pointer.any_down());
if !resized && !pending.retry_sent && !pointer_down && elapsed >= Duration::from_millis(80)
{
context.send_viewport_cmd(egui::ViewportCommand::InnerSize(pending.target));
pending.retry_sent = true;
}
if !resized && (elapsed < Duration::from_secs(1) || pointer_down) {
return;
}
if let Some(current) = current {
self.observed_window_size = Some(current);
}
self.pending_expansion = None;
}
fn track_base_window_size(&mut self, current: egui::Vec2) {
let changed = self.observed_window_size.is_some_and(|previous| {
(previous.x - current.x).abs() > 1.0 || (previous.y - current.y).abs() > 1.0
});
self.observed_window_size = Some(current);
if changed && self.panes.len() == 1 && self.pending_expansion.is_none() {
self.settings.base_width = current.x.round().clamp(320.0, 10_000.0) as i32;
self.settings.base_height = current.y.round().clamp(200.0, 10_000.0) as i32;
if let Err(error) = self.settings.save() {
self.error_message = Some(format!(
"{}: {error}",
tr(
Language::from_setting(&self.settings.language),
"Failed to save window size"
)
));
}
}
}
fn save_divider_positions(&mut self) {
if !self
.divider_dirty
.is_some_and(|changed_at| changed_at.elapsed() >= Duration::from_millis(300))
{
return;
}
self.divider_dirty = None;
if !self.settings.persist_dividers {
return;
}
self.settings.divider_columns = encode_weight_sets(&self.column_weights);
self.settings.divider_rows = encode_weight_sets(&self.row_weights);
if let Err(error) = self.settings.save() {
self.error_message = Some(format!(
"{}: {error}",
tr(
Language::from_setting(&self.settings.language),
"Failed to save settings"
)
));
}
}
fn draw_dividers(&mut self, ui: &mut egui::Ui, rect: egui::Rect, columns: usize, rows: usize) {
let gap = self.settings.pane_gap as f32;
let pointer_delta = ui.input(|input| input.pointer.delta());
let column_weights = normalized_weights(
self.column_weights.get(&columns).map(Vec::as_slice),
columns,
);
let row_weights = normalized_weights(self.row_weights.get(&rows).map(Vec::as_slice), rows);
self.column_weights.insert(columns, column_weights.clone());
self.row_weights.insert(rows, row_weights.clone());
let usable_width = (rect.width() - gap * columns.saturating_sub(1) as f32).max(1.0);
let usable_height = (rect.height() - gap * rows.saturating_sub(1) as f32).max(1.0);
let hit_width = gap.max(8.0);
let hit_height = gap.max(8.0);
let mut x = rect.left();
for index in 0..columns.saturating_sub(1) {
x += usable_width * column_weights[index];
let center = x + gap * 0.5;
let hit = egui::Rect::from_center_size(
egui::pos2(center, rect.center().y),
egui::vec2(hit_width, rect.height()),
);
let response = ui.interact(
hit,
ui.id().with(("column-divider", index)),
egui::Sense::drag(),
);
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
ui.painter()
.vline(center, rect.y_range(), egui::Stroke::new(1.0, self.focus));
}
if response.dragged()
&& pointer_delta.x != 0.0
&& let Some(weights) = self.column_weights.get_mut(&columns)
{
let minimum = (80.0 / usable_width).min(0.45);
let delta = pointer_delta.x / usable_width;
if weights[index] + delta >= minimum && weights[index + 1] - delta >= minimum {
weights[index] += delta;
weights[index + 1] -= delta;
self.divider_dirty = Some(Instant::now());
}
}
x += gap;
}
let mut y = rect.top();
for index in 0..rows.saturating_sub(1) {
y += usable_height * row_weights[index];
let center = y + gap * 0.5;
let hit = egui::Rect::from_center_size(
egui::pos2(rect.center().x, center),
egui::vec2(rect.width(), hit_height),
);
let response = ui.interact(
hit,
ui.id().with(("row-divider", index)),
egui::Sense::drag(),
);
if response.hovered() || response.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical);
ui.painter()
.hline(rect.x_range(), center, egui::Stroke::new(1.0, self.focus));
}
if response.dragged()
&& pointer_delta.y != 0.0
&& let Some(weights) = self.row_weights.get_mut(&rows)
{
let minimum = (80.0 / usable_height).min(0.45);
let delta = pointer_delta.y / usable_height;
if weights[index] + delta >= minimum && weights[index + 1] - delta >= minimum {
weights[index] += delta;
weights[index + 1] -= delta;
self.divider_dirty = Some(Instant::now());
}
}
y += gap;
}
}
fn close_active(&mut self, context: &egui::Context) {
if self.panes.len() == 1 {
return;
}
if self.panes[self.active].terminal.focus_events() {
let _ = self.panes[self.active].terminal.input(b"\x1b[O");
}
let mut pane = self.panes.remove(self.active);
let _ = pane.terminal.kill();
self.active = self.active.min(self.panes.len() - 1);
if self.panes[self.active].terminal.focus_events() {
let _ = self.panes[self.active].terminal.input(b"\x1b[I");
}
let base = egui::vec2(
self.settings.base_width as f32,
self.settings.base_height as f32,
);
let size = layout_window_size(
base,
self.panes.len(),
self.settings.pane_gap as f32,
&self.settings.pane_layout,
);
let size = self.constrain_to_work_area(context, size);
context.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
self.center_after_resize(context, size);
self.pending_expansion = Some(PendingExpansion {
target: size,
requested_at: Instant::now(),
retry_sent: false,
});
}
fn center_after_resize(&self, context: &egui::Context, size: egui::Vec2) {
if !self.settings.center_on_expand {
return;
}
if center_with_compositor() {
return;
}
if let Some(monitor) = context.input(|input| input.viewport().monitor_size) {
context.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
((monitor.x - size.x) / 2.0).max(0.0),
((monitor.y - size.y) / 2.0).max(0.0),
)));
}
}
fn constrain_to_work_area(&self, context: &egui::Context, size: egui::Vec2) -> egui::Vec2 {
let monitor = context.input(|input| input.viewport().monitor_size);
let work_area = desktop_work_area_size();
let available = match (monitor, work_area) {
(Some(monitor), Some(work_area)) => {
egui::vec2(monitor.x.min(work_area.x), monitor.y.min(work_area.y))
}
(Some(monitor), None) => monitor,
(None, Some(work_area)) => work_area,
(None, None) => return size,
};
egui::vec2(size.x.min(available.x), size.y.min(available.y))
}
fn focus_direction(&mut self, key: egui::Key) {
let origin = self.panes[self.active].rect.center();
let target = self
.panes
.iter()
.enumerate()
.filter(|(index, _)| *index != self.active)
.filter_map(|(index, pane)| {
let delta = pane.rect.center() - origin;
let valid = match key {
egui::Key::ArrowLeft => delta.x < 0.0,
egui::Key::ArrowRight => delta.x > 0.0,
egui::Key::ArrowUp => delta.y < 0.0,
egui::Key::ArrowDown => delta.y > 0.0,
_ => false,
};
valid.then_some((index, delta.length_sq()))
})
.min_by(|left, right| left.1.total_cmp(&right.1))
.map(|(index, _)| index);
if let Some(target) = target {
self.change_active(target);
}
}
fn change_active(&mut self, target: usize) {
if target == self.active || target >= self.panes.len() {
return;
}
if self.panes[self.active].terminal.focus_events() {
let _ = self.panes[self.active].terminal.input(b"\x1b[O");
}
self.active = target;
self.search_dirty = true;
if self.panes[self.active].terminal.focus_events() {
let _ = self.panes[self.active].terminal.input(b"\x1b[I");
}
self.cursor_epoch = Instant::now();
}
fn update_search(&mut self) {
if !self.show_search || !self.search_dirty {
return;
}
self.search_matches.clear();
self.search_pane = self.active;
self.search_dirty = false;
let query = self
.search_query
.chars()
.flat_map(char::to_lowercase)
.collect::<Vec<_>>();
if query.is_empty() {
self.search_current = 0;
return;
}
let screen = self.panes[self.active].terminal.screen();
let total_lines = screen.history_len() + screen.rows();
for line in 0..total_lines {
let mut characters = Vec::new();
let mut columns = Vec::new();
for column in 0..screen.columns() {
let cell = screen.line_cell(line, column);
if cell.width == 0 {
continue;
}
for character in cell.text().chars().flat_map(char::to_lowercase) {
characters.push(character);
columns.push((column, cell.width.max(1) as usize));
}
}
if characters.len() < query.len() {
continue;
}
for start in 0..=characters.len().saturating_sub(query.len()) {
if characters[start..start + query.len()] == query {
let first = columns[start].0;
let (last, width) = columns[start + query.len() - 1];
self.search_matches.push(SearchMatch {
line,
start: first,
end: (last + width - 1).min(screen.columns() - 1),
});
}
}
}
self.search_current = self.search_matches.len().saturating_sub(1);
self.reveal_search_match();
}
fn navigate_search(&mut self, forward: bool) {
if self.search_matches.is_empty() {
return;
}
self.search_current = if forward {
(self.search_current + 1) % self.search_matches.len()
} else {
(self.search_current + self.search_matches.len() - 1) % self.search_matches.len()
};
self.reveal_search_match();
}
fn reveal_search_match(&mut self) {
let Some(found) = self.search_matches.get(self.search_current).copied() else {
return;
};
let pane = &mut self.panes[self.active];
let screen = pane.terminal.screen();
pane.scroll_offset = (screen.history_len() + screen.rows() / 2)
.saturating_sub(found.line)
.min(screen.history_len());
}
fn draw_pane(&mut self, ui: &mut egui::Ui, index: usize, rect: egui::Rect) {
profiling::scope!("app.draw_pane");
self.panes[index].rect = rect;
let response = ui.allocate_rect(rect, egui::Sense::click_and_drag());
if response.clicked() {
self.change_active(index);
self.panes[index].selection = None;
self.panes[index].selection_anchor = None;
}
let bypass_mouse_reporting = ui.input(|input| input.modifiers.shift);
if self.panes[index].terminal.mouse_tracking() == 0 || bypass_mouse_reporting {
response.context_menu(|ui| {
let language = Language::from_setting(&self.settings.language);
apply_terminal_ui_theme(ui, self.background, self.foreground, self.focus);
if ui.button(tr(language, "Settings")).clicked() {
self.show_settings = true;
ui.close();
}
});
}
ui.painter().rect_filled(rect, 0.0, self.background);
if self.active == index {
let focus_animating = self.settings.focus_animation
&& self.cursor_epoch.elapsed() < Duration::from_millis(900);
let focus = if focus_animating {
let phase = self.cursor_epoch.elapsed().as_secs_f32() * std::f32::consts::TAU / 2.4;
let alpha = (215.0 + phase.sin() * 40.0) as u8;
egui::Color32::from_rgba_unmultiplied(
self.focus.r(),
self.focus.g(),
self.focus.b(),
alpha,
)
} else {
self.focus
};
paint_focus(
ui.painter(),
rect,
focus,
self.settings.focus_width as f32,
&self.settings.focus_style,
);
if focus_animating {
ui.ctx().request_repaint_after(Duration::from_millis(33));
}
}
let cell_width = self.font_metrics.cell_width;
let cell_height = self.font_metrics.line_height;
let columns = ((rect.width() - 8.0) / cell_width).floor().max(1.0) as usize;
let rows = ((rect.height() - 8.0) / cell_height).floor().max(1.0) as usize;
let foreground_default = self.foreground;
let background_default = self.background;
let palette = self.palette;
let colors_enabled = self.settings.colors;
let trim_selection = self.settings.trim_selection;
let copy_on_select = self.settings.copy_on_select;
let selection_foreground = self.selection_foreground;
let selection_background = self.selection_background;
let suggestion_color = self.suggestion_color;
let search_matches = if self.show_search && self.search_pane == index {
self.search_matches.as_slice()
} else {
&[]
};
let active_search_match = search_matches.get(self.search_current).copied();
let pane = &mut self.panes[index];
if pane.rows != rows || pane.columns != columns {
let screen = pane.terminal.screen();
let selection = pane.selection.map(|selection| {
(
screen.logical_position(selection.start.0, selection.start.1),
screen.logical_position(selection.end.0, selection.end.1),
)
});
let selection_anchor = pane
.selection_anchor
.map(|(anchor, position)| (screen.logical_position(anchor.0, anchor.1), position));
let viewport_anchor = (pane.scroll_offset > 0).then(|| {
screen.logical_position(screen.history_len().saturating_sub(pane.scroll_offset), 0)
});
pane.rows = rows;
pane.columns = columns;
let _ = pane.terminal.resize(
rows,
columns,
rect.width().min(u16::MAX as f32) as u16,
rect.height().min(u16::MAX as f32) as u16,
);
let screen = pane.terminal.screen();
pane.selection = selection.map(|(start, end)| Selection {
start: screen.physical_position(start.0, start.1),
end: screen.physical_position(end.0, end.1),
});
pane.selection_anchor = selection_anchor
.map(|(anchor, position)| (screen.physical_position(anchor.0, anchor.1), position));
if let Some(anchor) = viewport_anchor {
let (line, _) = screen.physical_position(anchor.0, anchor.1);
pane.scroll_offset = screen.history_len().saturating_sub(line);
}
if self.active == index && self.show_search {
self.search_dirty = true;
}
}
if pane.terminal.update() && self.active == index {
self.cursor_epoch = Instant::now();
if self.show_search {
self.search_dirty = true;
}
}
if let Some(title) = pane.terminal.take_title() {
pane.title = title;
}
if let Some(directory) = pane.terminal.take_working_directory() {
pane.working_directory = PathBuf::from(directory);
}
if pane.terminal.take_bell() {
pane.bell_epoch = Some(Instant::now());
play_bell();
}
let screen = pane.terminal.screen();
let history_len = screen.history_len();
pane.scroll_offset = pane.scroll_offset.min(history_len);
let origin = rect.min + egui::vec2(4.0, 4.0);
if (response.double_clicked() || response.triple_clicked())
&& let Some(position) = response.interact_pointer_pos()
{
let column = ((position.x - origin.x) / cell_width).floor().max(0.0) as usize;
let row = ((position.y - origin.y) / cell_height).floor().max(0.0) as usize;
let column = column.min(screen.columns() - 1);
let row = row.min(screen.rows() - 1);
let line = history_len + row - pane.scroll_offset.min(history_len + row);
pane.selection = if response.triple_clicked() {
Some(Selection {
start: (line, 0),
end: (line, screen.columns() - 1),
})
} else {
Some(word_selection(screen, line, column))
};
if let Some(text) = selected_text(pane, trim_selection) {
if copy_on_select {
ui.ctx().copy_text(text.clone());
}
publish_primary_selection(text);
}
}
if (pane.terminal.mouse_tracking() == 0 || bypass_mouse_reporting)
&& let Some(position) = response.interact_pointer_pos()
{
if response.dragged() {
let content_top = origin.y;
let content_bottom = origin.y + screen.rows() as f32 * cell_height;
if position.y < content_top {
let lines = ((content_top - position.y) / cell_height)
.ceil()
.clamp(1.0, 12.0) as usize;
pane.scroll_offset = (pane.scroll_offset + lines).min(history_len);
} else if position.y > content_bottom {
let lines = ((position.y - content_bottom) / cell_height)
.ceil()
.clamp(1.0, 12.0) as usize;
pane.scroll_offset = pane.scroll_offset.saturating_sub(lines);
}
}
let column = ((position.x - origin.x) / cell_width).floor().max(0.0) as usize;
let row = ((position.y - origin.y) / cell_height).floor().max(0.0) as usize;
let column = column.min(screen.columns() - 1);
let row = row.min(screen.rows() - 1);
let line = history_len + row - pane.scroll_offset.min(history_len + row);
if response.drag_started() {
pane.selection_anchor = Some(((line, column), position));
}
if response.dragged()
&& let Some((anchor, anchor_position)) = pane.selection_anchor
&& anchor_position.distance_sq(position) >= 9.0
{
pane.selection = Some(Selection {
start: anchor,
end: (line, column),
});
}
}
if response.drag_stopped() {
pane.selection_anchor = None;
if let Some(text) = selected_text(pane, trim_selection) {
if copy_on_select {
ui.ctx().copy_text(text.clone());
}
publish_primary_selection(text);
}
}
for row in 0..screen.rows() {
let line = history_len + row - pane.scroll_offset.min(history_len + row);
let row_has_emoji = (0..screen.columns())
.any(|column| is_emoji_text(&screen.line_cell(line, column).text()));
let mut row_spans: Vec<GlyphSpan> = Vec::new();
for column in 0..screen.columns() {
let cell = screen.line_cell(line, column);
if cell.width == 0 {
continue;
}
if column > 0 {
let previous = screen.line_cell(line, column - 1);
if previous.width == 0 && column > 1 {
let previous = screen.line_cell(line, column - 2);
if previous.combining.contains('\u{200d}') {
continue;
}
}
}
let visual_column = if row_has_emoji {
visual_column(screen, line, column)
} else {
column
};
let position = origin
+ egui::vec2(visual_column as f32 * cell_width, row as f32 * cell_height);
let (mut foreground, background) = cell_colors(
cell.rendition,
foreground_default,
background_default,
&palette,
colors_enabled,
suggestion_color,
);
let selected = pane
.selection
.is_some_and(|selection| selection_contains(selection, line, column));
if selected {
foreground = selection_foreground;
}
if !row_has_emoji {
let weight =
rendition_weight(self.settings.font_weight as u16, cell.rendition.bold);
let italic = cell.rendition.italic;
let text = if cell.rendition.hidden {
" ".repeat(cell.width.max(1) as usize)
} else {
cell.render_text()
};
if let Some(span) = row_spans.last_mut()
&& span.color == foreground
&& span.weight == weight
&& span.italic == italic
{
span.text.push_str(&text);
} else {
row_spans.push(GlyphSpan {
text,
color: foreground,
weight,
italic,
});
}
}
if background != self.background {
ui.painter().rect_filled(
egui::Rect::from_min_size(
position,
egui::vec2(cell_width * cell.width as f32, cell_height),
),
0.0,
background,
);
}
if selected {
ui.painter().rect_filled(
egui::Rect::from_min_size(
position,
egui::vec2(cell_width * cell.width.max(1) as f32, cell_height),
),
0.0,
selection_background,
);
}
if let Some(found) = search_matches
.iter()
.find(|found| found.line == line && (found.start..=found.end).contains(&column))
{
let active = active_search_match.is_some_and(|active| {
active.line == found.line
&& active.start == found.start
&& active.end == found.end
});
ui.painter().rect_filled(
egui::Rect::from_min_size(
position,
egui::vec2(cell_width * cell.width.max(1) as f32, cell_height),
),
0.0,
if active {
egui::Color32::from_rgba_unmultiplied(255, 145, 45, 190)
} else {
egui::Color32::from_rgba_unmultiplied(235, 205, 60, 105)
},
);
}
if let Some(uri) = &cell.hyperlink {
let link_rect = egui::Rect::from_min_size(
position,
egui::vec2(cell_width * cell.width.max(1) as f32, cell_height),
);
ui.painter().line_segment(
[link_rect.left_bottom(), link_rect.right_bottom()],
egui::Stroke::new(1.0, foreground),
);
let response = ui.interact(
link_rect,
ui.id().with(("osc8", index, line, column)),
egui::Sense::click(),
);
if response.hovered() && ui.input(|input| input.modifiers.command) {
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
}
if response.clicked() && ui.input(|input| input.modifiers.command) {
open_uri(uri.to_string());
}
}
if row_has_emoji && cell.character != ' ' && !cell.rendition.hidden {
let (text, _) = render_cluster(screen, line, column);
let family = if is_emoji_text(&text) {
"Noto Color Emoji".to_string()
} else {
terminal_font(&self.settings.font).0.to_string()
};
self.glyph_runs.push(GlyphRun {
spans: vec![GlyphSpan {
text,
color: foreground,
weight: rendition_weight(
self.settings.font_weight as u16,
cell.rendition.bold,
),
italic: cell.rendition.italic,
}],
position,
clip_bounds: rect,
family,
size: self.font_size,
cell_width,
line_height: cell_height,
descent: self.font_metrics.descent,
lcd: false,
});
}
}
if !row_has_emoji {
let position = origin + egui::vec2(0.0, row as f32 * cell_height);
self.glyph_runs.push(GlyphRun {
spans: row_spans,
position,
clip_bounds: rect,
family: terminal_font(&self.settings.font).0.to_string(),
size: self.font_size,
cell_width,
line_height: cell_height,
descent: self.font_metrics.descent,
lcd: false,
});
}
}
let cursor = screen.cursor();
let cursor_style = match self.settings.cursor_style.as_str() {
"block" => CursorStyle::Block,
"underline" => CursorStyle::Underline,
"bar" => CursorStyle::Bar,
_ => screen.cursor_style(),
};
let cursor_blinks = self.settings.cursor_blink && screen.cursor_blink();
let blink_on = self.settings.cursor_blink_on as u128;
let blink_off = self.settings.cursor_blink_off as u128;
let blink_position = self.cursor_epoch.elapsed().as_millis() % (blink_on + blink_off);
let cursor_blink_on = !cursor_blinks || blink_position < blink_on;
if cursor_blinks && self.active == index {
let remaining = if cursor_blink_on {
blink_on - blink_position
} else {
blink_on + blink_off - blink_position
};
ui.ctx()
.request_repaint_after(Duration::from_millis(remaining.max(1) as u64));
}
let cursor_allowed = cursor.visible || !screen.is_alternate();
let cursor_row = cursor.row.saturating_add(pane.scroll_offset);
if cursor_allowed && cursor_blink_on && self.active == index && cursor_row < screen.rows() {
let position = origin
+ egui::vec2(
visual_column(screen, history_len + cursor.row, cursor.column) as f32
* cell_width,
cursor_row as f32 * cell_height,
);
let cursor_rect = match cursor_style {
CursorStyle::Block => {
egui::Rect::from_min_size(position, egui::vec2(cell_width, cell_height))
}
CursorStyle::Underline => egui::Rect::from_min_size(
position + egui::vec2(0.0, cell_height - (self.font_size / 8.0).max(2.0)),
egui::vec2(cell_width, (self.font_size / 8.0).max(2.0)),
),
CursorStyle::Bar => egui::Rect::from_min_size(
position,
egui::vec2((self.font_size / 8.0).max(2.0), cell_height),
),
};
let cursor_color = parse_color(&self.settings.cursor_color).unwrap_or(self.focus);
ui.painter().rect_filled(cursor_rect, 0.0, cursor_color);
let cell = screen.cell(cursor.row, cursor.column);
if cursor_style == CursorStyle::Block && cell.character != ' ' && cell.width != 0 {
let text = cell.render_text();
if is_emoji_text(&text) {
ui.painter().with_clip_rect(cursor_rect).text(
position,
egui::Align2::LEFT_TOP,
text,
egui::FontId::monospace(self.font_size),
self.background,
);
} else {
self.glyph_runs.push(GlyphRun {
spans: vec![GlyphSpan {
text,
color: self.background,
weight: rendition_weight(
self.settings.font_weight as u16,
cell.rendition.bold,
),
italic: cell.rendition.italic,
}],
position,
clip_bounds: rect,
family: terminal_font(&self.settings.font).0.to_string(),
size: self.font_size,
cell_width,
line_height: cell_height,
descent: self.font_metrics.descent,
lcd: false,
});
}
}
}
if let Some(epoch) = pane.bell_epoch {
if epoch.elapsed() < Duration::from_millis(140) {
ui.painter().rect_filled(
rect,
0.0,
egui::Color32::from_rgba_unmultiplied(255, 255, 255, 42),
);
ui.ctx().request_repaint();
} else {
pane.bell_epoch = None;
}
}
}
fn settings_ui(&mut self, context: &egui::Context) {
if !self.show_settings {
return;
}
let mut open = self.show_settings;
let mut changed = false;
let language = Language::from_setting(&self.settings.language);
let previous_gap = self.settings.pane_gap;
let previous_layout = self.settings.pane_layout.clone();
let previous_persist_dividers = self.settings.persist_dividers;
let previous_font = self.settings.font.clone();
let previous_font_weight = self.settings.font_weight;
let mut close_clicked = false;
let settings_background = opaque(self.background);
egui::Window::new(tr(language, "Arreliny Settings"))
.open(&mut open)
.title_bar(false)
.resizable(true)
.default_width(560.0)
.frame(
egui::Frame::new()
.fill(settings_background)
.corner_radius(0)
.inner_margin(egui::Margin::same(16))
.stroke(egui::Stroke::new(1.0, self.focus)),
)
.show(context, |ui| {
apply_terminal_ui_theme(ui, self.background, self.foreground, self.focus);
ui.horizontal(|ui| {
ui.label(tr(language, "Language"));
changed |= ui
.selectable_value(
&mut self.settings.language,
"en".to_string(),
tr(language, "English"),
)
.changed();
changed |= ui
.selectable_value(
&mut self.settings.language,
"ru".to_string(),
tr(language, "Russian"),
)
.changed();
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui
.add(
egui::Button::new(egui::RichText::new("×").size(18.0)).frame(false),
)
.clicked()
{
close_clicked = true;
}
});
});
ui.add_space(10.0);
ui.horizontal(|ui| {
ui.selectable_value(
&mut self.settings_tab,
SettingsTab::Controls,
tr(language, "Controls"),
);
ui.selectable_value(
&mut self.settings_tab,
SettingsTab::Appearance,
tr(language, "Appearance"),
);
ui.selectable_value(
&mut self.settings_tab,
SettingsTab::Panes,
tr(language, "Panes"),
);
});
ui.separator();
ui.add_space(4.0);
egui::ScrollArea::vertical().show(ui, |ui| {
if self.settings_tab == SettingsTab::Controls {
settings_section(ui, tr(language, "Controls"));
binding_setting(
ui,
tr(language, "Add terminal"),
Binding::Spawn,
&self.settings.spawn_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Close terminal"),
Binding::Close,
&self.settings.close_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Focus left"),
Binding::Left,
&self.settings.focus_left_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Focus right"),
Binding::Right,
&self.settings.focus_right_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Focus up"),
Binding::Up,
&self.settings.focus_up_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Focus down"),
Binding::Down,
&self.settings.focus_down_accel,
&mut self.recording_binding,
language,
);
binding_setting(
ui,
tr(language, "Equalize panes"),
Binding::Equalize,
&self.settings.equalize_panes_accel,
&mut self.recording_binding,
language,
);
if self.recording_binding.is_some() {
ui.label(tr(language, "Press a shortcut. Escape cancels recording."));
}
ui.separator();
settings_section(ui, tr(language, "Mouse"));
ui.horizontal(|ui| {
ui.label(tr(language, "Lines per wheel step"));
changed |= ui
.add(egui::Slider::new(&mut self.settings.scroll_lines, 1..=20))
.changed();
});
ui.separator();
settings_section(ui, tr(language, "Clipboard"));
changed |= ui
.checkbox(
&mut self.settings.copy_on_select,
tr(language, "Copy selection to clipboard immediately"),
)
.changed();
changed |= ui
.checkbox(
&mut self.settings.middle_click_paste,
tr(language, "Paste primary selection with middle mouse button"),
)
.changed();
changed |= ui
.checkbox(
&mut self.settings.bracketed_paste,
tr(language, "Use protected bracketed paste"),
)
.changed();
changed |= ui
.checkbox(
&mut self.settings.trim_selection,
tr(language, "Trim trailing spaces from copied lines"),
)
.changed();
}
if self.settings_tab == SettingsTab::Appearance {
ui.separator();
settings_section(ui, tr(language, "Terminal appearance"));
changed |= ui
.checkbox(&mut self.settings.colors, tr(language, "ANSI colors"))
.changed();
changed |= ui
.checkbox(
&mut self.settings.center_on_expand,
tr(language, "Center window when grid changes"),
)
.changed();
ui.horizontal(|ui| {
let (family, size) = terminal_font(&self.settings.font);
let family = family.to_string();
ui.label(tr(language, "Font"));
ui.label(&family);
if ui.button(tr(language, "Choose…")).clicked() {
if self.system_fonts.is_empty() {
self.system_fonts = installed_font_families();
}
self.font_search.clear();
self.show_font_picker = true;
}
let mut size = size;
if ui
.add(
egui::DragValue::new(&mut size)
.range(6.0..=96.0)
.suffix(" pt"),
)
.changed()
{
self.settings.font = format!("{family} {size}");
install_terminal_font(
context,
&self.settings.font,
self.settings.font_weight,
);
self.font_size = size * 4.0 / 3.0;
changed = true;
}
});
ui.horizontal(|ui| {
ui.label(tr(language, "Font weight"));
egui::ComboBox::from_id_salt("font-weight")
.selected_text(font_weight_name(
language,
self.settings.font_weight,
))
.show_ui(ui, |ui| {
for weight in [300, 400, 500, 600, 700] {
changed |= ui
.selectable_value(
&mut self.settings.font_weight,
weight,
font_weight_name(language, weight),
)
.changed();
}
});
});
changed |= ui
.add(
egui::Slider::new(&mut self.settings.background_opacity, 10..=100)
.text(tr(language, "Opacity, %")),
)
.changed();
egui::ComboBox::from_label(tr(language, "Palette"))
.selected_text(match self.settings.palette.as_str() {
"pure-black" => "Pure Black",
"custom" => tr(language, "Custom"),
_ => "Catppuccin Mocha",
})
.show_ui(ui, |ui| {
changed |= ui
.selectable_value(
&mut self.settings.palette,
"catppuccin-mocha".to_string(),
"Catppuccin Mocha",
)
.changed();
changed |= ui
.selectable_value(
&mut self.settings.palette,
"pure-black".to_string(),
"Pure Black",
)
.changed();
changed |= ui
.selectable_value(
&mut self.settings.palette,
"custom".to_string(),
tr(language, "Custom"),
)
.changed();
});
if self.settings.palette == "custom" {
changed |= color_setting(
ui,
tr(language, "Background"),
&mut self.settings.custom_background,
);
changed |= color_setting(
ui,
tr(language, "Foreground"),
&mut self.settings.custom_foreground,
);
ui.label(tr(language, "ANSI colors"));
egui::Grid::new("custom-palette")
.num_columns(4)
.show(ui, |ui| {
for index in 0..16 {
ui.label(format!("{}", index + 1));
let mut color =
parse_color(&self.settings.custom_colors[index])
.unwrap_or(egui::Color32::WHITE);
if egui::color_picker::color_edit_button_srgba(
ui,
&mut color,
egui::color_picker::Alpha::Opaque,
)
.changed()
{
self.settings.custom_colors[index] = color_hex(color);
changed = true;
}
if index % 2 == 1 {
ui.end_row();
}
}
});
}
ui.separator();
settings_section(ui, tr(language, "Cursor"));
egui::ComboBox::from_label(tr(language, "Shape"))
.selected_text(match self.settings.cursor_style.as_str() {
"block" => tr(language, "Block"),
"underline" => tr(language, "Underline"),
"bar" => tr(language, "Vertical bar"),
_ => tr(language, "Controlled by application"),
})
.show_ui(ui, |ui| {
for (value, label) in [
("application", tr(language, "Controlled by application")),
("block", tr(language, "Block")),
("underline", tr(language, "Underline")),
("bar", tr(language, "Vertical bar")),
] {
changed |= ui
.selectable_value(
&mut self.settings.cursor_style,
value.to_string(),
label,
)
.changed();
}
});
changed |= color_setting(
ui,
tr(language, "Cursor color"),
&mut self.settings.cursor_color,
);
changed |= ui
.checkbox(
&mut self.settings.cursor_blink,
tr(language, "Cursor blinking"),
)
.changed();
if self.settings.cursor_blink {
changed |= ui
.add(
egui::Slider::new(
&mut self.settings.cursor_blink_on,
100..=2_000,
)
.text(tr(language, "Visible, ms")),
)
.changed();
changed |= ui
.add(
egui::Slider::new(
&mut self.settings.cursor_blink_off,
100..=2_000,
)
.text(tr(language, "Hidden, ms")),
)
.changed();
}
}
if self.settings_tab == SettingsTab::Appearance {
ui.separator();
settings_section(ui, tr(language, "Selection and text"));
changed |= color_setting(
ui,
tr(language, "Selection text"),
&mut self.settings.selection_foreground,
);
changed |= color_setting(
ui,
tr(language, "Selection background"),
&mut self.settings.selection_background,
);
changed |= color_setting(
ui,
tr(language, "Suggestions and dim text"),
&mut self.settings.suggestion_color,
);
ui.separator();
settings_section(ui, tr(language, "Active terminal"));
changed |= ui
.checkbox(
&mut self.settings.focus_animation,
tr(language, "Border animation"),
)
.changed();
changed |= color_setting(
ui,
tr(language, "Color"),
&mut self.settings.focus_color,
);
changed |= ui
.add(
egui::Slider::new(&mut self.settings.focus_width, 1..=12)
.text(tr(language, "Width")),
)
.changed();
egui::ComboBox::from_label(tr(language, "Style"))
.selected_text(match self.settings.focus_style.as_str() {
"dashed" => tr(language, "Dashed"),
"double" => tr(language, "Double"),
"none" => tr(language, "No border"),
_ => tr(language, "Solid"),
})
.show_ui(ui, |ui| {
for (value, label) in [
("solid", tr(language, "Solid")),
("dashed", tr(language, "Dashed")),
("double", tr(language, "Double")),
("none", tr(language, "No border")),
] {
changed |= ui
.selectable_value(
&mut self.settings.focus_style,
value.to_string(),
label,
)
.changed();
}
});
}
if self.settings_tab == SettingsTab::Panes {
settings_section(ui, tr(language, "Panes"));
changed |= ui
.add(
egui::Slider::new(&mut self.settings.pane_gap, 0..=32)
.text(tr(language, "Gap between terminals")),
)
.changed();
egui::ComboBox::from_label(tr(language, "Layout"))
.selected_text(match self.settings.pane_layout.as_str() {
"columns" => tr(language, "Columns"),
"grid" => tr(language, "Grid"),
_ => tr(language, "Automatic"),
})
.show_ui(ui, |ui| {
for (value, label) in [
("auto", tr(language, "Automatic")),
("columns", tr(language, "Columns")),
("grid", tr(language, "Grid")),
] {
changed |= ui
.selectable_value(
&mut self.settings.pane_layout,
value.to_string(),
label,
)
.changed();
}
});
changed |= ui
.add(
egui::Slider::new(&mut self.settings.max_panes, 1..=9)
.text(tr(language, "Maximum terminals")),
)
.changed();
changed |= ui
.checkbox(
&mut self.settings.persist_dividers,
tr(language, "Save divider positions"),
)
.changed();
}
});
});
if close_clicked {
open = false;
}
self.show_settings = open;
if previous_persist_dividers != self.settings.persist_dividers {
if self.settings.persist_dividers {
self.settings.divider_columns = encode_weight_sets(&self.column_weights);
self.settings.divider_rows = encode_weight_sets(&self.row_weights);
} else {
self.settings.divider_columns.clear();
self.settings.divider_rows.clear();
}
}
if previous_gap != self.settings.pane_gap || previous_layout != self.settings.pane_layout {
let pane = self.panes[0].rect.size();
let gap = self.settings.pane_gap as f32;
let size = layout_window_size(pane, self.panes.len(), gap, &self.settings.pane_layout);
let size = self.constrain_to_work_area(context, size);
if self.panes.len() > 1 {
context.send_viewport_cmd(egui::ViewportCommand::InnerSize(size));
self.center_after_resize(context, size);
self.pending_expansion = Some(PendingExpansion {
target: size,
requested_at: Instant::now(),
retry_sent: false,
});
}
}
self.font_picker_ui(context, &mut changed);
if previous_font != self.settings.font || previous_font_weight != self.settings.font_weight
{
self.font_metrics = measure_font(&self.settings, self.font_size);
}
if changed {
let (foreground, background, palette) = theme(&self.settings);
self.foreground = foreground;
self.background = background;
self.palette = palette;
self.focus =
parse_color(&self.settings.focus_color).unwrap_or_else(|| rgb(0xf0, 0xb3, 0x5a));
self.selection_foreground =
parse_color(&self.settings.selection_foreground).unwrap_or(egui::Color32::WHITE);
self.selection_background = parse_color(&self.settings.selection_background)
.unwrap_or_else(|| rgb(0x5a, 0x78, 0xb4));
self.suggestion_color = parse_color(&self.settings.suggestion_color)
.unwrap_or_else(|| rgb(0x7f, 0x84, 0x9c));
if let Err(error) = self.settings.save() {
self.error_message = Some(format!(
"{}: {error}",
tr(language, "Failed to save settings")
));
}
}
}
fn error_ui(&mut self, context: &egui::Context) {
let Some(message) = self.error_message.clone() else {
return;
};
let language = Language::from_setting(&self.settings.language);
egui::Window::new(tr(language, "Arreliny Error"))
.collapsible(false)
.resizable(false)
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 24.0))
.show(context, |ui| {
ui.colored_label(egui::Color32::from_rgb(255, 120, 120), message);
if ui.button(tr(language, "Close")).clicked() {
self.error_message = None;
}
});
}
fn font_picker_ui(&mut self, context: &egui::Context, changed: &mut bool) {
if !self.show_font_picker {
return;
}
let mut open = self.show_font_picker;
let mut selected = None;
let language = Language::from_setting(&self.settings.language);
egui::Window::new(tr(language, "System fonts"))
.open(&mut open)
.default_width(440.0)
.default_height(520.0)
.show(context, |ui| {
ui.add(
egui::TextEdit::singleline(&mut self.font_search)
.hint_text(tr(language, "Search fonts…")),
);
let query = self.font_search.to_lowercase();
egui::ScrollArea::vertical().show(ui, |ui| {
for family in &self.system_fonts {
if (query.is_empty() || family.to_lowercase().contains(&query))
&& ui.selectable_label(false, family).clicked()
{
selected = Some(family.clone());
}
}
});
});
if let Some(family) = selected {
let size = terminal_font(&self.settings.font).1;
self.settings.font = format!("{family} {size}");
install_terminal_font(context, &self.settings.font, self.settings.font_weight);
self.font_size = size * 4.0 / 3.0;
*changed = true;
open = false;
}
self.show_font_picker = open;
}
fn search_ui(&mut self, context: &egui::Context) {
if !self.show_search {
return;
}
let language = Language::from_setting(&self.settings.language);
egui::Window::new(tr(language, "Search"))
.title_bar(false)
.resizable(false)
.collapsible(false)
.anchor(egui::Align2::RIGHT_TOP, egui::vec2(-10.0, 10.0))
.show(context, |ui| {
ui.horizontal(|ui| {
let response = ui.add(
egui::TextEdit::singleline(&mut self.search_query)
.hint_text(tr(language, "Search history…"))
.desired_width(260.0),
);
if self.search_focus {
response.request_focus();
self.search_focus = false;
}
if response.changed() {
self.search_dirty = true;
}
let count = if self.search_matches.is_empty() {
"0/0".to_string()
} else {
format!("{}/{}", self.search_current + 1, self.search_matches.len())
};
ui.label(count);
if ui.button("↑").clicked() {
self.navigate_search(false);
}
if ui.button("↓").clicked() {
self.navigate_search(true);
}
if ui.button("×").clicked() {
self.show_search = false;
}
});
});
}
}
impl eframe::App for Arreliny {
fn logic(&mut self, context: &egui::Context, _: &mut eframe::Frame) {
profiling::scope!("app.logic");
self.process_input(context);
self.finish_expansion(context);
self.save_divider_positions();
if self.pending_expansion.is_some() {
context.request_repaint_after(Duration::from_millis(16));
}
if self.divider_dirty.is_some() {
context.request_repaint_after(Duration::from_millis(50));
}
}
fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
profiling::scope!("app.ui");
self.update_search();
self.glyph_runs.clear();
let visible = ui.max_rect();
self.track_base_window_size(visible.size());
let (layout_rect, layout_count) = self
.pending_expansion
.as_ref()
.map(|pending| {
(
egui::Rect::from_min_size(visible.min, pending.target),
self.panes.len(),
)
})
.unwrap_or((visible, self.panes.len()));
let (columns, rows) = layout_dimensions(layout_count, &self.settings.pane_layout);
let column_weights = normalized_weights(
self.column_weights.get(&columns).map(Vec::as_slice),
columns,
);
let row_weights = normalized_weights(self.row_weights.get(&rows).map(Vec::as_slice), rows);
let panes = pane_rects(
layout_rect,
layout_count,
self.settings.pane_gap as f32,
&self.settings.pane_layout,
&column_weights,
&row_weights,
);
for (index, rect) in panes.into_iter().take(self.panes.len()).enumerate() {
self.draw_pane(ui, index, rect);
}
self.draw_dividers(ui, layout_rect, columns, rows);
let pane_title = self.panes[self.active].title.trim();
let window_title = if pane_title.is_empty() {
"Arreliny".to_string()
} else {
format!("{pane_title} — Arreliny")
};
if self.window_title != window_title {
ui.ctx()
.send_viewport_cmd(egui::ViewportCommand::Title(window_title.clone()));
self.window_title = window_title;
}
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
visible,
TerminalTextCallback {
runs: std::mem::take(&mut self.glyph_runs),
},
));
self.search_ui(ui.ctx());
self.settings_ui(ui.ctx());
self.error_ui(ui.ctx());
profiling::finish_frame!();
#[cfg(feature = "tracy")]
if let Some(client) = tracy_client::Client::running() {
client.frame_mark();
}
}
fn clear_color(&self, _: &egui::Visuals) -> [f32; 4] {
[0.0, 0.0, 0.0, 0.0]
}
fn on_exit(&mut self) {
let _ = self.settings.save();
}
}
fn paint_focus(
painter: &egui::Painter,
rect: egui::Rect,
color: egui::Color32,
width: f32,
style: &str,
) {
if style == "none" {
return;
}
let stroke = egui::Stroke::new(width, color);
if style == "dashed" {
let dash = (width * 3.0).max(6.0);
let gap = dash * 0.65;
let rect = rect.shrink(width / 2.0);
paint_dashed_line(
painter,
rect.left_top(),
rect.right_top(),
dash,
gap,
stroke,
);
paint_dashed_line(
painter,
rect.right_top(),
rect.right_bottom(),
dash,
gap,
stroke,
);
paint_dashed_line(
painter,
rect.right_bottom(),
rect.left_bottom(),
dash,
gap,
stroke,
);
paint_dashed_line(
painter,
rect.left_bottom(),
rect.left_top(),
dash,
gap,
stroke,
);
return;
}
painter.rect_stroke(rect, 0.0, stroke, egui::StrokeKind::Inside);
if style == "double" {
painter.rect_stroke(
rect.shrink(width * 2.0 + 1.0),
0.0,
stroke,
egui::StrokeKind::Inside,
);
}
}
fn paint_dashed_line(
painter: &egui::Painter,
start: egui::Pos2,
end: egui::Pos2,
dash: f32,
gap: f32,
stroke: egui::Stroke,
) {
let vector = end - start;
let length = vector.length();
if length == 0.0 {
return;
}
let direction = vector / length;
let mut offset = 0.0;
while offset < length {
let segment_end = (offset + dash).min(length);
painter.line_segment(
[start + direction * offset, start + direction * segment_end],
stroke,
);
offset += dash + gap;
}
}
fn binding_setting(
ui: &mut egui::Ui,
label: &str,
binding: Binding,
accelerator: &str,
recording: &mut Option<Binding>,
language: Language,
) {
ui.horizontal(|ui| {
ui.label(label);
let text = if *recording == Some(binding) {
tr(language, "Press a shortcut…")
} else {
accelerator
};
if ui.button(text).clicked() {
*recording = Some(binding);
}
});
}
fn settings_section(ui: &mut egui::Ui, label: &str) {
ui.add_space(8.0);
ui.label(
egui::RichText::new(label)
.size(13.0)
.strong()
.color(ui.visuals().selection.bg_fill),
);
ui.add_space(4.0);
ui.separator();
ui.add_space(4.0);
}
fn apply_terminal_ui_theme(
ui: &mut egui::Ui,
background: egui::Color32,
foreground: egui::Color32,
accent: egui::Color32,
) {
let background = opaque(background);
let subtle = mix_color(background, foreground, 0.12);
let hover = mix_color(background, accent, 0.28);
let active = mix_color(background, accent, 0.42);
let visuals = &mut ui.style_mut().visuals;
visuals.dark_mode = color_luminance(background) < 0.5;
visuals.override_text_color = Some(foreground);
visuals.weak_text_color = Some(mix_color(background, foreground, 0.68));
visuals.window_fill = background;
visuals.panel_fill = background;
visuals.extreme_bg_color = mix_color(background, foreground, 0.07);
visuals.faint_bg_color = subtle;
visuals.selection.bg_fill = accent;
visuals.selection.stroke = egui::Stroke::new(1.0, foreground);
visuals.hyperlink_color = accent;
visuals.widgets.noninteractive.bg_fill = background;
visuals.widgets.noninteractive.fg_stroke = egui::Stroke::new(1.0, foreground);
visuals.widgets.inactive.bg_fill = subtle;
visuals.widgets.inactive.weak_bg_fill = subtle;
visuals.widgets.inactive.fg_stroke = egui::Stroke::new(1.0, foreground);
visuals.widgets.hovered.bg_fill = hover;
visuals.widgets.hovered.weak_bg_fill = hover;
visuals.widgets.hovered.fg_stroke = egui::Stroke::new(1.0, foreground);
visuals.widgets.active.bg_fill = active;
visuals.widgets.active.weak_bg_fill = active;
visuals.widgets.active.fg_stroke = egui::Stroke::new(1.0, foreground);
visuals.widgets.open = visuals.widgets.active;
}
fn opaque(color: egui::Color32) -> egui::Color32 {
egui::Color32::from_rgb(color.r(), color.g(), color.b())
}
fn mix_color(left: egui::Color32, right: egui::Color32, amount: f32) -> egui::Color32 {
let mix =
|left: u8, right: u8| (left as f32 + (right as f32 - left as f32) * amount).round() as u8;
egui::Color32::from_rgb(
mix(left.r(), right.r()),
mix(left.g(), right.g()),
mix(left.b(), right.b()),
)
}
fn color_luminance(color: egui::Color32) -> f32 {
(color.r() as f32 * 0.2126 + color.g() as f32 * 0.7152 + color.b() as f32 * 0.0722) / 255.0
}
fn color_setting(ui: &mut egui::Ui, label: &str, value: &mut String) -> bool {
let mut color = parse_color(value).unwrap_or(egui::Color32::WHITE);
let mut changed = false;
ui.horizontal(|ui| {
ui.label(label);
if egui::color_picker::color_edit_button_srgba(
ui,
&mut color,
egui::color_picker::Alpha::Opaque,
)
.changed()
{
*value = color_hex(color);
changed = true;
}
});
changed
}
fn color_hex(color: egui::Color32) -> String {
format!("#{:02x}{:02x}{:02x}", color.r(), color.g(), color.b())
}
fn spawn_pane(
settings: &Settings,
font_metrics: FontMetrics,
directory: Option<&Path>,
context: &egui::Context,
) -> Result<Pane, String> {
let directory = directory
.filter(|path| path.is_dir())
.map(Path::to_path_buf)
.unwrap_or_else(|| env::current_dir().unwrap_or_else(|_| "/".into()));
let width = settings.base_width.max(1) as f32;
let height = settings.base_height.max(1) as f32;
let columns = ((width - 8.0) / font_metrics.cell_width).floor().max(1.0) as usize;
let rows = ((height - 8.0) / font_metrics.line_height).floor().max(1.0) as usize;
let terminal = TerminalSession::spawn(
&directory,
rows,
columns,
width.min(u16::MAX as f32) as u16,
height.min(u16::MAX as f32) as u16,
{
let context = context.clone();
Arc::new(move || context.request_repaint())
},
)?;
Ok(Pane {
terminal,
rows,
columns,
scroll_offset: 0,
scroll_remainder: 0.0,
selection: None,
selection_anchor: None,
title: String::new(),
rect: egui::Rect::NOTHING,
working_directory: directory,
bell_epoch: None,
})
}
fn measure_font(settings: &Settings, font_size: f32) -> FontMetrics {
let family = terminal_font(&settings.font).0;
font_metrics(family, settings.font_weight as u16, font_size)
}
fn mouse_button_code(button: egui::PointerButton) -> Option<u8> {
match button {
egui::PointerButton::Primary => Some(0),
egui::PointerButton::Middle => Some(1),
egui::PointerButton::Secondary => Some(2),
_ => None,
}
}
fn send_terminal_mouse(
pane: &mut Pane,
button: u8,
column: usize,
row: usize,
pressed: bool,
modifiers: egui::Modifiers,
) {
let modifier_code =
u8::from(modifiers.shift) * 4 + u8::from(modifiers.alt) * 8 + u8::from(modifiers.ctrl) * 16;
let code = button + modifier_code;
if pane.terminal.sgr_mouse() {
let terminator = if pressed { 'M' } else { 'm' };
let sequence = format!("\x1b[<{code};{};{}{terminator}", column + 1, row + 1);
let _ = pane.terminal.input(sequence.as_bytes());
} else {
let code = if pressed { code } else { 3 };
let sequence = [
0x1b,
b'[',
b'M',
code.saturating_add(32),
(column.min(222) as u8).saturating_add(33),
(row.min(222) as u8).saturating_add(33),
];
let _ = pane.terminal.input(&sequence);
}
}
fn paste_bytes(pane: &mut Pane, bytes: &[u8], bracketed_paste: bool) {
pane.scroll_offset = 0;
if bracketed_paste && pane.terminal.bracketed_paste() {
let _ = pane.terminal.input(b"\x1b[200~");
let _ = pane.terminal.input(bytes);
let _ = pane.terminal.input(b"\x1b[201~");
} else {
let _ = pane.terminal.input(bytes);
}
}
fn terminal_path(path: &Path) -> String {
let path = path.to_string_lossy();
if path.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'/' | b'.' | b'_' | b'-' | b':' | b'@')
}) {
return path.into_owned();
}
format!("'{}'", path.replace('\'', "'\\''"))
}
fn terminal_font(value: &str) -> (&str, f32) {
let value = value.trim();
let Some((family, size)) = value.rsplit_once(' ') else {
return (value, 14.0);
};
match size.parse::<f32>() {
Ok(size) if (6.0..=96.0).contains(&size) => (family.trim(), size),
_ => (value, 14.0),
}
}
fn font_weight_name(language: Language, weight: i32) -> &'static str {
match weight {
300 => tr(language, "Light"),
400 => tr(language, "Regular"),
500 => tr(language, "Medium"),
600 => tr(language, "Semi-bold"),
700 => tr(language, "Bold"),
_ => tr(language, "Custom weight"),
}
}
fn rendition_weight(weight: u16, bold: bool) -> u16 {
if !bold {
return weight;
}
[100, 300, 400, 700, 900]
.into_iter()
.find(|candidate| *candidate > weight)
.unwrap_or(900)
}
fn is_emoji_text(text: &str) -> bool {
text.chars().any(|character| {
matches!(
character as u32,
0x00a9
| 0x00ae
| 0x203c
| 0x2049
| 0x2122
| 0x2139
| 0x2194..=0x2199
| 0x21a9..=0x21aa
| 0x231a..=0x231b
| 0x2328
| 0x23cf
| 0x23e9..=0x23f3
| 0x23f8..=0x23fa
| 0x24c2
| 0x25aa..=0x25ab
| 0x25b6
| 0x25c0
| 0x25fb..=0x25fe
| 0x2600..=0x27bf
| 0x2934..=0x2935
| 0x2b05..=0x2b07
| 0x2b1b..=0x2b1c
| 0x2b50
| 0x2b55
| 0x3030
| 0x303d
| 0x3297
| 0x3299
| 0x1f000..=0x1faff
)
})
}
fn render_cluster(screen: &Screen, line: usize, column: usize) -> (String, usize) {
let first = screen.line_cell(line, column);
if !first.combining.contains('\u{200d}') {
return (first.render_text(), first.width.max(1) as usize);
}
let mut text = first.text();
let mut next = column + first.width.max(1) as usize;
while next < screen.columns() {
let cell = screen.line_cell(line, next);
if cell.width == 0 || !is_emoji_text(&cell.text()) {
break;
}
text.push_str(&cell.text());
next += cell.width.max(1) as usize;
if !cell.combining.contains('\u{200d}') {
break;
}
}
(text, 2)
}
fn visual_column(screen: &Screen, line: usize, logical_column: usize) -> usize {
let mut logical = 0;
let mut visual = 0;
while logical < logical_column && logical < screen.columns() {
let cell = screen.line_cell(line, logical);
if cell.width == 0 {
logical += 1;
continue;
}
if cell.combining.contains('\u{200d}') {
let (_, cluster_width) = render_cluster(screen, line, logical);
let mut cluster_logical_width = cell.width.max(1) as usize;
let mut next = logical + cluster_logical_width;
while next < screen.columns() {
let next_cell = screen.line_cell(line, next);
if next_cell.width == 0 || !is_emoji_text(&next_cell.text()) {
break;
}
let next_width = next_cell.width.max(1) as usize;
cluster_logical_width += next_width;
next += next_width;
if !next_cell.combining.contains('\u{200d}') {
break;
}
}
if logical + cluster_logical_width <= logical_column {
logical += cluster_logical_width;
visual += cluster_width;
continue;
}
}
let width = cell.width.max(1) as usize;
logical += width;
visual += width;
}
visual
}
fn install_terminal_font(context: &egui::Context, setting: &str, weight: i32) {
let (family, _) = terminal_font(setting);
let mut database = fontdb::Database::new();
database.load_system_fonts();
let families = [fontdb::Family::Name(family), fontdb::Family::Monospace];
let Some(id) = database.query(&fontdb::Query {
families: &families,
weight: fontdb::Weight(weight as u16),
..Default::default()
}) else {
return;
};
let Some((bytes, index)) = database.with_face_data(id, |data, index| (data.to_vec(), index))
else {
return;
};
let mut data = egui::FontData::from_owned(bytes);
data.index = index;
let name = "arreliny".to_string();
let mut definitions = egui::FontDefinitions::default();
definitions.font_data.insert(name.clone(), data.into());
definitions
.families
.entry(egui::FontFamily::Monospace)
.or_default()
.insert(0, name);
context.set_fonts(definitions);
}
fn installed_font_families() -> Vec<String> {
let mut database = fontdb::Database::new();
database.load_system_fonts();
let mut families = database
.faces()
.flat_map(|face| face.families.iter().map(|(family, _)| family.clone()))
.collect::<Vec<_>>();
families.sort_by_key(|family| family.to_lowercase());
families.dedup_by(|left, right| left.eq_ignore_ascii_case(right));
families
}
fn settings_warning(settings: &Settings) -> Option<String> {
let mut invalid = Vec::new();
for (name, value) in [
("cursor_color", settings.cursor_color.as_str()),
(
"selection_foreground",
settings.selection_foreground.as_str(),
),
(
"selection_background",
settings.selection_background.as_str(),
),
("suggestion_color", settings.suggestion_color.as_str()),
("focus_color", settings.focus_color.as_str()),
] {
if parse_color(value).is_none() {
invalid.push(name.to_string());
}
}
if settings.palette == "custom" {
if parse_color(&settings.custom_foreground).is_none() {
invalid.push("custom_foreground".to_string());
}
if parse_color(&settings.custom_background).is_none() {
invalid.push("custom_background".to_string());
}
for (index, color) in settings.custom_colors.iter().enumerate() {
if parse_color(color).is_none() {
invalid.push(format!("custom_color_{index}"));
}
}
}
(!invalid.is_empty()).then(|| {
format!(
"{}: {}",
tr(
Language::from_setting(&settings.language),
"Invalid color settings"
),
invalid.join(", ")
)
})
}
fn resolve_color(
color: Color,
default: egui::Color32,
palette: &[egui::Color32; 16],
) -> egui::Color32 {
match color {
Color::Default => default,
Color::Indexed(index) if index < 16 => palette[index as usize],
Color::Indexed(index) if index < 232 => {
let index = index - 16;
let red = index / 36;
let green = index % 36 / 6;
let blue = index % 6;
rgb(cube(red), cube(green), cube(blue))
}
Color::Indexed(index) => {
let value = 8 + (index - 232) * 10;
rgb(value, value, value)
}
Color::Rgb(red, green, blue) => rgb(red, green, blue),
}
}
fn cell_colors(
rendition: Rendition,
foreground_default: egui::Color32,
background_default: egui::Color32,
palette: &[egui::Color32; 16],
colors_enabled: bool,
suggestion_color: egui::Color32,
) -> (egui::Color32, egui::Color32) {
let mut foreground = if colors_enabled {
resolve_color(rendition.foreground, foreground_default, palette)
} else {
foreground_default
};
let mut background = if colors_enabled {
resolve_color(rendition.background, background_default, palette)
} else {
background_default
};
if rendition.dim || rendition.foreground == Color::Indexed(8) {
foreground = suggestion_color;
}
if rendition.inverse {
std::mem::swap(&mut foreground, &mut background);
}
(foreground, background)
}
fn cube(value: u8) -> u8 {
if value == 0 { 0 } else { 55 + value * 40 }
}
fn rgb(red: u8, green: u8, blue: u8) -> egui::Color32 {
egui::Color32::from_rgb(red, green, blue)
}
fn theme(settings: &Settings) -> (egui::Color32, egui::Color32, [egui::Color32; 16]) {
let (foreground, mut background, palette) = match settings.palette.as_str() {
"pure-black" => (
rgb(0xe8, 0xe8, 0xe8),
rgb(0, 0, 0),
[
"#1a1a1a", "#ff5555", "#50fa7b", "#f1fa8c", "#8be9fd", "#bd93f9", "#8be9fd",
"#e8e8e8", "#555555", "#ff6e6e", "#69ff94", "#ffffa5", "#d6acff", "#ff92df",
"#a4ffff", "#ffffff",
]
.map(|value| parse_color(value).unwrap_or(egui::Color32::WHITE)),
),
"custom" => (
parse_color(&settings.custom_foreground).unwrap_or_else(|| rgb(0xe8, 0xe8, 0xe8)),
parse_color(&settings.custom_background).unwrap_or_else(|| rgb(0, 0, 0)),
std::array::from_fn(|index| {
parse_color(&settings.custom_colors[index]).unwrap_or(egui::Color32::WHITE)
}),
),
_ => (
rgb(0xcd, 0xd6, 0xf4),
rgb(0x11, 0x11, 0x1b),
[
"#45475a", "#f38ba8", "#a6e3a1", "#f9e2af", "#89b4fa", "#cba6f7", "#94e2d5",
"#bac2de", "#585b70", "#f38ba8", "#a6e3a1", "#f9e2af", "#89b4fa", "#cba6f7",
"#94e2d5", "#a6adc8",
]
.map(|value| parse_color(value).unwrap_or(egui::Color32::WHITE)),
),
};
background = egui::Color32::from_rgba_unmultiplied(
background.r(),
background.g(),
background.b(),
(settings.background_opacity * 255 / 100) as u8,
);
(foreground, background, palette)
}
fn parse_color(value: &str) -> Option<egui::Color32> {
if let Some(hex) = value.strip_prefix('#') {
return match hex.len() {
6 => Some(rgb(
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
)),
8 => Some(egui::Color32::from_rgba_unmultiplied(
u8::from_str_radix(&hex[0..2], 16).ok()?,
u8::from_str_radix(&hex[2..4], 16).ok()?,
u8::from_str_radix(&hex[4..6], 16).ok()?,
u8::from_str_radix(&hex[6..8], 16).ok()?,
)),
_ => None,
};
}
let body = value
.strip_prefix("rgba(")
.or_else(|| value.strip_prefix("rgb("))?
.strip_suffix(')')?;
let mut values = body.split(',').map(str::trim);
let red = values.next()?.parse().ok()?;
let green = values.next()?.parse().ok()?;
let blue = values.next()?.parse().ok()?;
let alpha = values
.next()
.map(str::parse::<f32>)
.transpose()
.ok()?
.map(|alpha| (alpha.clamp(0.0, 1.0) * 255.0) as u8)
.unwrap_or(255);
if values.next().is_some() {
return None;
}
Some(egui::Color32::from_rgba_unmultiplied(
red, green, blue, alpha,
))
}
pub(crate) fn run() -> eframe::Result {
#[cfg(feature = "tracy")]
let _tracy = tracy_client::Client::start();
#[cfg(feature = "tracy")]
{
use tracing_subscriber::prelude::*;
let subscriber = tracing_subscriber::registry().with(tracing_tracy::TracyLayer::default());
tracing::subscriber::set_global_default(subscriber)
.expect("failed to install Tracy subscriber");
}
profiling::register_thread!("Arreliny UI");
#[cfg(target_os = "linux")]
unsafe {
env::set_var("DISABLE_MANGOHUD", "1");
}
let settings = Settings::load();
let options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
persist_window: false,
viewport: egui::ViewportBuilder::default()
.with_title("Arreliny")
.with_app_id("arreliny")
.with_icon(application_icon())
.with_inner_size([settings.base_width as f32, settings.base_height as f32])
.with_transparent(true),
..Default::default()
};
eframe::run_native(
"Arreliny",
options,
Box::new(move |creation| {
install_terminal_font(&creation.egui_ctx, &settings.font, settings.font_weight);
if let Some(render_state) = &creation.wgpu_render_state {
let (family, points) = terminal_font(&settings.font);
render_state
.renderer
.write()
.callback_resources
.insert(GlyphRenderer::new(
&render_state.device,
&render_state.queue,
render_state.target_format,
family,
points * 4.0 / 3.0,
settings.font_weight as u16,
creation.egui_ctx.pixels_per_point(),
));
}
Arreliny::new(settings, &creation.egui_ctx)
.map(|application| Box::new(application) as Box<dyn eframe::App>)
.map_err(|error| std::io::Error::other(error).into())
}),
)
}
fn application_icon() -> egui::IconData {
eframe::icon_data::from_png_bytes(include_bytes!("../../assets/arreliny.png"))
.expect("embedded application icon must be a valid PNG")
}