cosmic-terminal/src/main.rs
Ashley Wulber 20beeb608a
chore: update libcosmic
keyboard input has changed a lot in iced, but I think I've made the proper adjustments here. vertical splits don't seem to work, but that didn't seem to work before either. Also, there is a black border on the right and bottom of the first tab of the terminal. I'm not sure why yet, but it seems like it is probably related to the scrollbar
2024-02-08 16:57:14 -05:00

1693 lines
65 KiB
Rust

// Copyright 2023 System76 <info@system76.com>
// SPDX-License-Identifier: GPL-3.0-only
use alacritty_terminal::{
event::Event as TermEvent, term::color::Colors as TermColors, term::Config as TermConfig, tty,
};
use cosmic::{
app::{message, Command, Core, Settings},
cosmic_config::{self, CosmicConfigEntry},
cosmic_theme, executor,
iced::{
advanced::graphics::text::font_system,
clipboard, event,
futures::SinkExt,
keyboard::{Event as KeyEvent, Key, Modifiers},
subscription::{self, Subscription},
window, Alignment, Color, Event, Length, Limits, Padding, Point,
},
style,
widget::{self, button, container, pane_grid, segmented_button, PaneGrid},
Application, ApplicationExt, Element,
};
use cosmic_text::{fontdb::FaceInfo, Family, Stretch, Weight};
use std::{
any::TypeId,
collections::{BTreeMap, BTreeSet, HashMap},
env, process,
sync::{atomic::Ordering, Mutex},
};
use tokio::sync::mpsc;
use config::{AppTheme, Config, CONFIG_VERSION};
mod config;
mod mouse_reporter;
use icon_cache::IconCache;
mod icon_cache;
use key_bind::{key_binds, KeyBind};
mod key_bind;
mod localize;
use menu::menu_bar;
mod menu;
use terminal::{Terminal, TerminalPaneGrid, TerminalScroll};
mod terminal;
use terminal_box::terminal_box;
mod terminal_box;
mod terminal_theme;
lazy_static::lazy_static! {
static ref ICON_CACHE: Mutex<IconCache> = Mutex::new(IconCache::new());
}
pub fn icon_cache_get(name: &'static str, size: u16) -> widget::icon::Icon {
let mut icon_cache = ICON_CACHE.lock().unwrap();
icon_cache.get(name, size)
}
/// Runs application with these settings
#[rustfmt::skip]
fn main() -> Result<(), Box<dyn std::error::Error>> {
#[cfg(all(unix, not(target_os = "redox")))]
match fork::daemon(true, true) {
Ok(fork::Fork::Child) => (),
Ok(fork::Fork::Parent(_child_pid)) => process::exit(0),
Err(err) => {
eprintln!("failed to daemonize: {:?}", err);
process::exit(1);
}
}
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init();
localize::localize();
let (config_handler, config) = match cosmic_config::Config::new(App::APP_ID, CONFIG_VERSION) {
Ok(config_handler) => {
let config = match Config::get_entry(&config_handler) {
Ok(ok) => ok,
Err((errs, config)) => {
log::info!("errors loading config: {:?}", errs);
config
}
};
(Some(config_handler), config)
}
Err(err) => {
log::error!("failed to create config handler: {}", err);
(None, Config::default())
}
};
let mut shell_program_opt = None;
let mut shell_args = Vec::new();
let mut parse_flags = true;
for arg in env::args().skip(1) {
if parse_flags {
match arg.as_str() {
// These flags indicate the end of parsing flags
"-e" | "--command" | "--" => {
parse_flags = false;
}
_ => {
//TODO: should this throw an error?
log::warn!("ignored argument {:?}", arg);
}
}
} else if shell_program_opt.is_none() {
shell_program_opt = Some(arg);
} else {
shell_args.push(arg);
}
}
let startup_options = if let Some(shell_program) = shell_program_opt {
let options = tty::Options {
shell: Some(tty::Shell::new(shell_program, shell_args)),
..tty::Options::default()
};
Some(options)
} else {
None
};
let term_config = TermConfig::default();
// Set up environmental variables for terminal
tty::setup_env();
// Override TERM for better compatibility
env::set_var("TERM", "xterm-256color");
let mut settings = Settings::default();
settings = settings.theme(config.app_theme.theme());
#[cfg(target_os = "redox")]
{
// Redox does not support resize if doing CSDs
settings = settings.client_decorations(false);
}
settings = settings.size_limits(Limits::NONE.min_width(360.0).min_height(180.0));
let flags = Flags {
config_handler,
config,
startup_options,
term_config,
};
cosmic::app::run::<App>(settings, flags)?;
Ok(())
}
#[derive(Clone, Debug)]
pub struct Flags {
config_handler: Option<cosmic_config::Config>,
config: Config,
startup_options: Option<tty::Options>,
term_config: TermConfig,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Action {
Copy,
Find,
PaneFocusDown,
PaneFocusLeft,
PaneFocusRight,
PaneFocusUp,
PaneSplitHorizontal,
PaneSplitVertical,
PaneToggleMaximized,
Paste,
SelectAll,
Settings,
ShowHeaderBar(bool),
TabActivate0,
TabActivate1,
TabActivate2,
TabActivate3,
TabActivate4,
TabActivate5,
TabActivate6,
TabActivate7,
TabActivate8,
TabClose,
TabNew,
TabNext,
TabPrev,
WindowClose,
WindowNew,
ZoomIn,
ZoomOut,
ZoomReset,
}
impl Action {
pub fn message(self, entity_opt: Option<segmented_button::Entity>) -> Message {
match self {
Action::Copy => Message::Copy(entity_opt),
Action::Find => Message::Find(true),
Action::PaneFocusDown => Message::PaneFocusAdjacent(pane_grid::Direction::Down),
Action::PaneFocusLeft => Message::PaneFocusAdjacent(pane_grid::Direction::Left),
Action::PaneFocusRight => Message::PaneFocusAdjacent(pane_grid::Direction::Right),
Action::PaneFocusUp => Message::PaneFocusAdjacent(pane_grid::Direction::Up),
Action::PaneSplitHorizontal => Message::PaneSplit(pane_grid::Axis::Horizontal),
Action::PaneSplitVertical => Message::PaneSplit(pane_grid::Axis::Vertical),
Action::PaneToggleMaximized => Message::PaneToggleMaximized,
Action::Paste => Message::Paste(entity_opt),
Action::SelectAll => Message::SelectAll(entity_opt),
Action::Settings => Message::ToggleContextPage(ContextPage::Settings),
Action::ShowHeaderBar(show_headerbar) => Message::ShowHeaderBar(show_headerbar),
Action::TabActivate0 => Message::TabActivateJump(0),
Action::TabActivate1 => Message::TabActivateJump(1),
Action::TabActivate2 => Message::TabActivateJump(2),
Action::TabActivate3 => Message::TabActivateJump(3),
Action::TabActivate4 => Message::TabActivateJump(4),
Action::TabActivate5 => Message::TabActivateJump(5),
Action::TabActivate6 => Message::TabActivateJump(6),
Action::TabActivate7 => Message::TabActivateJump(7),
Action::TabActivate8 => Message::TabActivateJump(8),
Action::TabClose => Message::TabClose(entity_opt),
Action::TabNew => Message::TabNew,
Action::TabNext => Message::TabNext,
Action::TabPrev => Message::TabPrev,
Action::WindowClose => Message::WindowClose,
Action::WindowNew => Message::WindowNew,
Action::ZoomIn => Message::ZoomIn,
Action::ZoomOut => Message::ZoomOut,
Action::ZoomReset => Message::ZoomReset,
}
}
}
/// Messages that are used specifically by our [`App`].
#[derive(Clone, Debug)]
pub enum Message {
AppTheme(AppTheme),
Config(Config),
Copy(Option<segmented_button::Entity>),
DefaultFont(usize),
DefaultFontSize(usize),
DefaultFontStretch(usize),
DefaultFontWeight(usize),
DefaultDimFontWeight(usize),
DefaultBoldFontWeight(usize),
DefaultZoomStep(usize),
Key(Modifiers, Key),
Find(bool),
FindNext,
FindPrevious,
FindSearchValueChanged(String),
PaneClicked(pane_grid::Pane),
PaneSplit(pane_grid::Axis),
PaneToggleMaximized,
PaneFocusAdjacent(pane_grid::Direction),
PaneDragged(pane_grid::DragEvent),
PaneResized(pane_grid::ResizeEvent),
Modifiers(Modifiers),
MouseEnter(pane_grid::Pane),
Paste(Option<segmented_button::Entity>),
PasteValue(Option<segmented_button::Entity>, String),
SelectAll(Option<segmented_button::Entity>),
UseBrightBold(bool),
ShowHeaderBar(bool),
SyntaxTheme(usize, bool),
SystemThemeChange,
TabActivate(segmented_button::Entity),
TabActivateJump(usize),
TabClose(Option<segmented_button::Entity>),
TabContextAction(segmented_button::Entity, Action),
TabContextMenu(pane_grid::Pane, Option<Point>),
TabNew,
TabPrev,
TabNext,
TermEvent(pane_grid::Pane, segmented_button::Entity, TermEvent),
TermEventTx(mpsc::Sender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>),
ToggleContextPage(ContextPage),
ShowAdvancedFontSettings(bool),
WindowClose,
WindowNew,
ZoomIn,
ZoomOut,
ZoomReset,
FocusFollowMouse(bool),
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ContextPage {
Settings,
}
impl ContextPage {
fn title(&self) -> String {
match self {
Self::Settings => fl!("settings"),
}
}
}
/// The [`App`] stores application-specific state.
pub struct App {
core: Core,
pane_model: TerminalPaneGrid,
config_handler: Option<cosmic_config::Config>,
config: Config,
key_binds: HashMap<KeyBind, Action>,
app_themes: Vec<String>,
font_names: Vec<String>,
font_size_names: Vec<String>,
font_sizes: Vec<u16>,
font_name_faces_map: BTreeMap<String, Vec<FaceInfo>>,
all_font_weights_vals_names_map: BTreeMap<u16, String>,
all_font_stretches_vals_names_map: BTreeMap<Stretch, String>,
curr_font_weight_names: Vec<String>,
curr_font_weights: Vec<u16>,
curr_font_stretch_names: Vec<String>,
curr_font_stretches: Vec<Stretch>,
zoom_adj: i8,
zoom_step_names: Vec<String>,
zoom_steps: Vec<u16>,
theme_names: Vec<String>,
themes: HashMap<String, TermColors>,
context_page: ContextPage,
terminal_ids: HashMap<pane_grid::Pane, widget::Id>,
find: bool,
find_search_id: widget::Id,
find_search_value: String,
term_event_tx_opt: Option<mpsc::Sender<(pane_grid::Pane, segmented_button::Entity, TermEvent)>>,
startup_options: Option<tty::Options>,
term_config: TermConfig,
show_advanced_font_settings: bool,
modifiers: Modifiers,
}
impl App {
fn update_config(&mut self) -> Command<Message> {
//TODO: provide iterator over data
let panes: Vec<_> = self.pane_model.panes.iter().collect();
for (_pane, tab_model) in panes {
let entities: Vec<_> = tab_model.iter().collect();
for entity in entities {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.set_config(&self.config, &self.themes, self.zoom_adj);
}
}
}
self.core.window.show_headerbar = self.config.show_headerbar;
cosmic::app::command::set_theme(self.config.app_theme.theme())
}
fn save_config(&mut self) -> Command<Message> {
if let Some(ref config_handler) = self.config_handler {
match self.config.write_entry(config_handler) {
Ok(()) => {}
Err(err) => {
log::error!("failed to save config: {}", err);
}
}
}
self.update_config()
}
fn update_focus(&self) -> Command<Message> {
if self.find {
widget::text_input::focus(self.find_search_id.clone())
} else if let Some(terminal_id) = self.terminal_ids.get(&self.pane_model.focus).cloned() {
widget::text_input::focus(terminal_id)
} else {
Command::none()
}
}
// Call this any time the tab changes
fn update_title(&mut self, pane: Option<pane_grid::Pane>) -> Command<Message> {
let pane = pane.unwrap_or(self.pane_model.focus);
if let Some(tab_model) = self.pane_model.panes.get(pane) {
let (header_title, window_title) = match tab_model.text(tab_model.active()) {
Some(tab_title) => (
tab_title.to_string(),
format!("{tab_title} — COSMIC Terminal"),
),
None => (String::new(), "COSMIC Terminal".to_string()),
};
self.set_header_title(header_title);
Command::batch([self.set_window_title(window_title), self.update_focus()])
} else {
log::error!("Failed to get the specific pane");
Command::batch([
self.set_window_title("COSMIC Terminal".to_string()),
self.update_focus(),
])
}
}
fn set_curr_font_weights_and_stretches(&mut self) {
// check if config font_name is available first, if not, set it to first name in list
if !self.font_names.contains(&self.config.font_name) {
log::error!("'{}' is not in the font list", self.config.font_name);
log::error!("setting font name to '{}'", self.font_names[0]);
let _ = self.update(Message::DefaultFont(0));
}
let curr_font_faces = &self.font_name_faces_map[&self.config.font_name];
self.curr_font_stretches = curr_font_faces
.iter()
.map(|face| face.stretch)
.collect::<BTreeSet<_>>() // remove duplicates and sort
.into_iter()
.collect();
self.curr_font_stretch_names = self
.curr_font_stretches
.iter()
.map(|stretch| &self.all_font_stretches_vals_names_map[stretch])
.cloned()
.collect::<Vec<_>>();
if !self
.curr_font_stretches
.contains(&self.config.typed_font_stretch())
{
self.config.font_stretch = Stretch::Normal.to_number();
}
let curr_weights = |conf_stretch| {
curr_font_faces
.iter()
.filter(|face| face.stretch == conf_stretch)
.map(|face| face.weight.0)
.collect::<BTreeSet<_>>() // remove duplicates and sort
.into_iter()
.collect()
};
self.curr_font_weights = curr_weights(self.config.typed_font_stretch());
if self.curr_font_weights.is_empty() {
// stretch fallback
self.config.font_stretch = Stretch::Normal.to_number();
}
self.curr_font_weights = curr_weights(self.config.typed_font_stretch());
assert!(!self.curr_font_weights.is_empty());
self.curr_font_weight_names = self
.curr_font_weights
.iter()
.map(|weight| &self.all_font_weights_vals_names_map[weight])
.cloned()
.collect::<Vec<_>>();
if !self.curr_font_weights.contains(&self.config.font_weight) {
self.config.font_weight = Weight::NORMAL.0;
}
if !self
.curr_font_weights
.contains(&self.config.dim_font_weight)
{
self.config.dim_font_weight = Weight::NORMAL.0;
}
if !self
.curr_font_weights
.contains(&self.config.bold_font_weight)
{
self.config.bold_font_weight = Weight::BOLD.0;
}
}
fn settings(&self) -> Element<Message> {
let app_theme_selected = match self.config.app_theme {
AppTheme::Dark => 1,
AppTheme::Light => 2,
AppTheme::System => 0,
};
let dark_selected = self
.theme_names
.iter()
.position(|theme_name| theme_name == &self.config.syntax_theme_dark);
let light_selected = self
.theme_names
.iter()
.position(|theme_name| theme_name == &self.config.syntax_theme_light);
let font_selected = {
let mut font_system = font_system().write().unwrap();
let current_font_name = font_system.raw().db().family_name(&Family::Monospace);
self.font_names
.iter()
.position(|font_name| font_name == current_font_name)
};
let font_size_selected = self
.font_sizes
.iter()
.position(|font_size| font_size == &self.config.font_size);
let font_stretch_selected = self
.curr_font_stretches
.iter()
.position(|font_stretch| font_stretch == &self.config.typed_font_stretch());
let font_weight_selected = self
.curr_font_weights
.iter()
.position(|font_weight| font_weight == &self.config.font_weight);
let dim_font_weight_selected = self
.curr_font_weights
.iter()
.position(|font_weight| font_weight == &self.config.dim_font_weight);
let bold_font_weight_selected = self
.curr_font_weights
.iter()
.position(|font_weight| font_weight == &self.config.bold_font_weight);
let zoom_step_selected = self
.zoom_steps
.iter()
.position(|zoom_step| zoom_step == &self.config.font_size_zoom_step_mul_100);
let appearance_section = widget::settings::view_section(fl!("appearance"))
.add(
widget::settings::item::builder(fl!("theme")).control(widget::dropdown(
&self.app_themes,
Some(app_theme_selected),
move |index| {
Message::AppTheme(match index {
1 => AppTheme::Dark,
2 => AppTheme::Light,
_ => AppTheme::System,
})
},
)),
)
.add(
//TODO: rename to color-scheme-dark?
widget::settings::item::builder(fl!("syntax-dark")).control(widget::dropdown(
&self.theme_names,
dark_selected,
move |index| Message::SyntaxTheme(index, true),
)),
)
.add(
//TODO: rename to color-scheme-light?
widget::settings::item::builder(fl!("syntax-light")).control(widget::dropdown(
&self.theme_names,
light_selected,
move |index| Message::SyntaxTheme(index, false),
)),
)
.add(
widget::settings::item::builder(fl!("default-zoom-step")).control(
widget::dropdown(&self.zoom_step_names, zoom_step_selected, |index| {
Message::DefaultZoomStep(index)
}),
),
);
//TODO: background opacity
let mut font_section = widget::settings::view_section(fl!("font"))
.add(
widget::settings::item::builder(fl!("default-font")).control(widget::dropdown(
&self.font_names,
font_selected,
Message::DefaultFont,
)),
)
.add(
widget::settings::item::builder(fl!("default-font-size")).control(
widget::dropdown(&self.font_size_names, font_size_selected, |index| {
Message::DefaultFontSize(index)
}),
),
)
.add(
widget::settings::item::builder(fl!("advanced-font-settings")).toggler(
self.show_advanced_font_settings,
Message::ShowAdvancedFontSettings,
),
);
let advanced_font_settings = || {
let section = widget::settings::view_section("")
.add(
widget::settings::item::builder(fl!("default-font-stretch")).control(
widget::dropdown(
&self.curr_font_stretch_names,
font_stretch_selected,
Message::DefaultFontStretch,
),
),
)
.add(
widget::settings::item::builder(fl!("default-font-weight")).control(
widget::dropdown(
&self.curr_font_weight_names,
font_weight_selected,
Message::DefaultFontWeight,
),
),
)
.add(
widget::settings::item::builder(fl!("default-dim-font-weight")).control(
widget::dropdown(
&self.curr_font_weight_names,
dim_font_weight_selected,
Message::DefaultDimFontWeight,
),
),
)
.add(
widget::settings::item::builder(fl!("default-bold-font-weight")).control(
widget::dropdown(
&self.curr_font_weight_names,
bold_font_weight_selected,
Message::DefaultBoldFontWeight,
),
),
)
.add(
widget::settings::item::builder(fl!("use-bright-bold"))
.toggler(self.config.use_bright_bold, Message::UseBrightBold),
);
let padding = Padding {
top: 0.0,
bottom: 0.0,
left: 12.0,
right: 12.0,
};
widget::container(section).padding(padding)
};
if self.show_advanced_font_settings {
font_section = font_section.add(advanced_font_settings());
}
let splits_section = widget::settings::view_section(fl!("splits")).add(
widget::settings::item::builder(fl!("focus-follow-mouse"))
.toggler(self.config.focus_follow_mouse, Message::FocusFollowMouse),
);
let advanced_section = widget::settings::view_section(fl!("advanced")).add(
widget::settings::item::builder(fl!("show-headerbar"))
.description(fl!("show-header-description"))
.toggler(self.config.show_headerbar, Message::ShowHeaderBar),
);
widget::settings::view_column(vec![
appearance_section.into(),
font_section.into(),
splits_section.into(),
advanced_section.into(),
])
.into()
}
fn create_and_focus_new_terminal(&mut self, pane: pane_grid::Pane) {
self.pane_model.focus = pane;
match &self.term_event_tx_opt {
Some(term_event_tx) => match self.themes.get(self.config.syntax_theme()) {
Some(colors) => {
let current_pane = self.pane_model.focus;
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = tab_model
.insert()
.text("New Terminal")
.closable()
.activate()
.id();
// Use the startup options, or defaults
let options = self.startup_options.take().unwrap_or_default();
let mut terminal = Terminal::new(
current_pane,
entity,
term_event_tx.clone(),
self.term_config.clone(),
options,
&self.config,
*colors,
);
terminal.set_config(&self.config, &self.themes, self.zoom_adj);
tab_model.data_set::<Mutex<Terminal>>(entity, Mutex::new(terminal));
} else {
log::error!("Found no active pane");
}
}
None => {
log::error!(
"failed to find terminal theme {:?}",
self.config.syntax_theme()
);
//TODO: fall back to known good theme
}
},
None => {
log::warn!("tried to create new tab before having event channel");
}
}
}
}
/// Implement [`Application`] to integrate with COSMIC.
impl Application for App {
/// Default async executor to use with the app.
type Executor = executor::Default;
/// Argument received
type Flags = Flags;
/// Message type specific to our [`App`].
type Message = Message;
/// The unique application ID to supply to the window manager.
const APP_ID: &'static str = "com.system76.CosmicTerm";
fn core(&self) -> &Core {
&self.core
}
fn core_mut(&mut self) -> &mut Core {
&mut self.core
}
/// Creates the application, and optionally emits command on initialize.
fn init(mut core: Core, flags: Self::Flags) -> (Self, Command<Self::Message>) {
core.window.content_container = false;
core.window.show_headerbar = flags.config.show_headerbar;
// Update font name from config
{
let mut font_system = font_system().write().unwrap();
font_system
.raw()
.db_mut()
.set_monospace_family(&flags.config.font_name);
}
let app_themes = vec![fl!("match-desktop"), fl!("dark"), fl!("light")];
let font_name_faces_map = {
let mut font_name_faces_map = BTreeMap::<_, Vec<_>>::new();
let mut font_system = font_system().write().unwrap();
//TODO: do not repeat, used in Tab::new
for face in font_system.raw().db().faces() {
// only monospace fonts and weights that match named constants.
let weight = face.weight.0;
if face.monospaced && { 1..9 }.contains(&{ weight / 100 }) && weight % 100 == 0 {
//TODO: get localized name if possible
let font_name = face
.families
.first()
.map_or_else(|| face.post_script_name.to_string(), |x| x.0.to_string());
font_name_faces_map
.entry(font_name)
.or_default()
.push(face.clone());
}
}
// only keep fonts that have both NORMAL and BOLD weights with both having
// a `Stretch::Normal` face.
// This is important for fallbacks.
font_name_faces_map.retain(|_, v| {
let has_normal = v
.iter()
.any(|face| face.weight == Weight::NORMAL && face.stretch == Stretch::Normal);
let has_bold = v
.iter()
.any(|face| face.weight == Weight::BOLD && face.stretch == Stretch::Normal);
has_normal && has_bold
});
font_name_faces_map
};
let font_names = font_name_faces_map.keys().cloned().collect();
let mut font_size_names = Vec::new();
let mut font_sizes = Vec::new();
for font_size in 4..=32 {
font_size_names.push(format!("{}px", font_size));
font_sizes.push(font_size);
}
let mut all_font_weights_vals_names_map = BTreeMap::new();
macro_rules! populate_font_weights {
($($weight:ident,)+) => {
// all weights
paste::paste!{
$(
all_font_weights_vals_names_map
.insert(Weight::$weight.0, stringify!([<$weight:camel>]).into());
)+
}
};
}
populate_font_weights! {
THIN, EXTRA_LIGHT, LIGHT, NORMAL, MEDIUM,
SEMIBOLD, BOLD, EXTRA_BOLD, BLACK,
};
let mut all_font_stretches_vals_names_map = BTreeMap::new();
macro_rules! populate_font_stretches {
($($stretch:ident,)+) => {
// all stretches
$(
all_font_stretches_vals_names_map
.insert(Stretch::$stretch, stringify!($stretch).into());
)+
};
}
populate_font_stretches! {
UltraCondensed, ExtraCondensed, Condensed, SemiCondensed,
Normal, SemiExpanded, Expanded, ExtraExpanded, UltraExpanded,
};
let mut zoom_step_names = Vec::new();
let mut zoom_steps = Vec::new();
for zoom_step in [25, 50, 75, 100, 150, 200] {
zoom_step_names.push(format!("{}px", f32::from(zoom_step) / 100.0));
zoom_steps.push(zoom_step);
}
let themes = terminal_theme::terminal_themes();
let mut theme_names: Vec<_> = themes.keys().cloned().collect();
theme_names.sort();
let pane_model = TerminalPaneGrid::new(segmented_button::ModelBuilder::default().build());
let mut terminal_ids = HashMap::new();
terminal_ids.insert(pane_model.focus, widget::Id::unique());
let mut app = App {
core,
pane_model,
config_handler: flags.config_handler,
config: flags.config,
key_binds: key_binds(),
app_themes,
font_names,
font_size_names,
font_sizes,
font_name_faces_map,
all_font_weights_vals_names_map,
all_font_stretches_vals_names_map,
curr_font_weight_names: Vec::new(),
curr_font_weights: Vec::new(),
curr_font_stretch_names: Vec::new(),
curr_font_stretches: Vec::new(),
zoom_adj: 0,
zoom_step_names,
zoom_steps,
theme_names,
themes,
context_page: ContextPage::Settings,
terminal_ids,
find: false,
find_search_id: widget::Id::unique(),
find_search_value: String::new(),
startup_options: flags.startup_options,
term_config: flags.term_config,
term_event_tx_opt: None,
show_advanced_font_settings: false,
modifiers: Modifiers::empty(),
};
app.set_curr_font_weights_and_stretches();
let command = app.update_title(None);
(app, command)
}
//TODO: currently the first escape unfocuses, and the second calls this function
fn on_escape(&mut self) -> Command<Message> {
if self.core.window.show_context {
// Close context drawer if open
self.core.window.show_context = false;
} else if self.find {
// Close find if open
self.find = false;
self.find_search_value.clear();
}
// Focus correct widget
self.update_focus()
}
fn on_context_drawer(&mut self) -> Command<Message> {
if !self.core.window.show_context {
self.update_focus()
} else {
Command::none()
}
}
/// Handle application events here.
fn update(&mut self, message: Self::Message) -> Command<Self::Message> {
match message {
Message::AppTheme(app_theme) => {
self.config.app_theme = app_theme;
return self.save_config();
}
Message::Config(config) => {
if config != self.config {
log::info!("update config");
//TODO: update syntax theme by clearing tabs, only if needed
self.config = config;
return self.update_config();
}
}
Message::Copy(entity_opt) => {
if let Some(tab_model) = self.pane_model.active() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
let term = terminal.term.lock();
if let Some(text) = term.selection_to_string() {
return Command::batch([clipboard::write(text), self.update_focus()]);
}
}
} else {
log::warn!("Failed to get focused pane");
}
return self.update_focus();
}
Message::DefaultFont(index) => {
match self.font_names.get(index) {
Some(font_name) => {
if font_name != &self.config.font_name {
// Update font name from config
{
let mut font_system = font_system().write().unwrap();
font_system.raw().db_mut().set_monospace_family(font_name);
}
let panes: Vec<_> = self.pane_model.panes.iter().collect();
for (_pane, tab_model) in panes {
let entities: Vec<_> = tab_model.iter().collect();
for entity in entities {
if let Some(terminal) =
tab_model.data::<Mutex<Terminal>>(entity)
{
let mut terminal = terminal.lock().unwrap();
terminal.update_cell_size();
}
}
}
self.config.font_name = font_name.to_string();
self.set_curr_font_weights_and_stretches();
return self.save_config();
}
}
None => {
log::warn!("failed to find font with index {}", index);
}
}
}
Message::DefaultFontSize(index) => match self.font_sizes.get(index) {
Some(font_size) => {
self.config.font_size = *font_size;
self.zoom_adj = 0; // reset zoom
return self.save_config();
}
None => {
log::warn!("failed to find font with index {}", index);
}
},
Message::DefaultFontStretch(index) => match self.curr_font_stretches.get(index) {
Some(font_stretch) => {
self.config.font_stretch = font_stretch.to_number();
self.set_curr_font_weights_and_stretches();
return self.save_config();
}
None => {
log::warn!("failed to find font weight with index {}", index);
}
},
Message::DefaultFontWeight(index) => match self.curr_font_weights.get(index) {
Some(font_weight) => {
self.config.font_weight = *font_weight;
return self.save_config();
}
None => {
log::warn!("failed to find font weight with index {}", index);
}
},
Message::DefaultDimFontWeight(index) => match self.curr_font_weights.get(index) {
Some(font_weight) => {
self.config.dim_font_weight = *font_weight;
return self.save_config();
}
None => {
log::warn!("failed to find dim font weight with index {}", index);
}
},
Message::DefaultBoldFontWeight(index) => match self.curr_font_weights.get(index) {
Some(font_weight) => {
self.config.bold_font_weight = *font_weight;
return self.save_config();
}
None => {
log::warn!("failed to find bold font weight with index {}", index);
}
},
Message::DefaultZoomStep(index) => match self.zoom_steps.get(index) {
Some(zoom_step) => {
self.config.font_size_zoom_step_mul_100 = *zoom_step;
self.zoom_adj = 0; // reset zoom
return self.save_config();
}
None => {
log::warn!("failed to find zoom step with index {}", index);
}
},
Message::Key(modifiers, key) => {
for (key_bind, action) in self.key_binds.iter() {
if key_bind.matches(modifiers, key.clone()) {
return self.update(action.message(None));
}
}
}
Message::Find(find) => {
self.find = find;
if find {
if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
let term = terminal.term.lock();
if let Some(text) = term.selection_to_string() {
self.find_search_value = text;
}
}
} else {
log::warn!("Failed to get focused pane");
}
} else {
self.find_search_value.clear();
}
// Focus correct input
return self.update_focus();
}
Message::FindNext => {
if !self.find_search_value.is_empty() {
if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.search(&self.find_search_value, true);
}
}
}
// Focus correct input
return self.update_focus();
}
Message::FindPrevious => {
if !self.find_search_value.is_empty() {
if let Some(tab_model) = self.pane_model.active() {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.search(&self.find_search_value, false);
}
}
}
// Focus correct input
return self.update_focus();
}
Message::FindSearchValueChanged(value) => {
self.find_search_value = value;
}
Message::Modifiers(modifiers) => {
self.modifiers = modifiers;
}
Message::MouseEnter(pane) => {
self.pane_model.focus = pane;
return self.update_focus();
}
Message::PaneClicked(pane) => {
self.pane_model.focus = pane;
return self.update_title(Some(pane));
}
Message::PaneSplit(axis) => {
let result = self.pane_model.panes.split(
axis,
self.pane_model.focus,
segmented_button::ModelBuilder::default().build(),
);
if let Some((pane, _)) = result {
self.terminal_ids.insert(pane, widget::Id::unique());
self.create_and_focus_new_terminal(pane);
self.pane_model.panes_created += 1;
return self.update_title(Some(pane));
}
}
Message::PaneToggleMaximized => {
if self.pane_model.panes.maximized().is_some() {
self.pane_model.panes.restore();
} else {
self.pane_model.panes.maximize(self.pane_model.focus);
}
return self.update_focus();
}
Message::PaneFocusAdjacent(direction) => {
if let Some(adjacent) = self
.pane_model
.panes
.adjacent(self.pane_model.focus, direction)
{
self.pane_model.focus = adjacent;
return self.update_title(Some(adjacent));
}
}
Message::PaneResized(pane_grid::ResizeEvent { split, ratio }) => {
self.pane_model.panes.resize(split, ratio);
}
Message::PaneDragged(pane_grid::DragEvent::Dropped { pane, target }) => {
self.pane_model.panes.drop(pane, target);
}
Message::PaneDragged(_) => {}
Message::Paste(entity_opt) => {
return clipboard::read(move |value_opt| match value_opt {
Some(value) => message::app(Message::PasteValue(entity_opt, value)),
None => message::none(),
});
}
Message::PasteValue(entity_opt, value) => {
if let Some(tab_model) = self.pane_model.active() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
terminal.paste(value);
}
}
return self.update_focus();
}
Message::SelectAll(entity_opt) => {
if let Some(tab_model) = self.pane_model.active() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.select_all();
}
}
return self.update_focus();
}
Message::ShowHeaderBar(show_headerbar) => {
if show_headerbar != self.config.show_headerbar {
self.config.show_headerbar = show_headerbar;
return self.save_config();
}
}
Message::UseBrightBold(use_bright_bold) => {
if use_bright_bold != self.config.use_bright_bold {
self.config.use_bright_bold = use_bright_bold;
return self.save_config();
}
}
Message::FocusFollowMouse(focus_follow_mouse) => {
if focus_follow_mouse != self.config.focus_follow_mouse {
self.config.focus_follow_mouse = focus_follow_mouse;
return self.save_config();
}
}
Message::SystemThemeChange => {
return self.update_config();
}
Message::SyntaxTheme(index, dark) => match self.theme_names.get(index) {
Some(theme_name) => {
if dark {
self.config.syntax_theme_dark = theme_name.to_string();
} else {
self.config.syntax_theme_light = theme_name.to_string();
}
return self.save_config();
}
None => {
log::warn!("failed to find syntax theme with index {}", index);
}
},
Message::TabActivate(entity) => {
if let Some(tab_model) = self.pane_model.active_mut() {
tab_model.activate(entity);
}
return self.update_title(None);
}
Message::TabActivateJump(pos) => {
if let Some(tab_model) = self.pane_model.active() {
// Length is always at least one so there shouldn't be a division by zero
let len = tab_model.iter().count();
// The typical pattern is that 1-8 selects tabs 1-8 while 9 selects the last tab
let pos = if pos >= 8 || pos > len - 1 {
len - 1
} else {
pos % len
};
let entity = tab_model.iter().nth(pos);
if let Some(entity) = entity {
return self.update(Message::TabActivate(entity));
}
}
}
Message::TabClose(entity_opt) => {
if let Some(tab_model) = self.pane_model.active_mut() {
let entity = entity_opt.unwrap_or_else(|| tab_model.active());
// Activate closest item
if let Some(position) = tab_model.position(entity) {
if position > 0 {
tab_model.activate_position(position - 1);
} else {
tab_model.activate_position(position + 1);
}
}
// Remove item
tab_model.remove(entity);
// If that was the last tab, close current pane
if tab_model.iter().next().is_none() {
if let Some((_state, sibling)) =
self.pane_model.panes.close(self.pane_model.focus)
{
self.terminal_ids.remove(&self.pane_model.focus);
self.pane_model.focus = sibling;
} else {
//Last pane, closing window
return window::close(window::Id::MAIN);
}
}
}
return self.update_title(None);
}
Message::TabContextAction(entity, action) => {
if let Some(tab_model) = self.pane_model.active() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
// Close context menu
{
let mut terminal = terminal.lock().unwrap();
terminal.context_menu = None;
}
// Run action's message
return self.update(action.message(Some(entity)));
}
}
}
Message::TabContextMenu(pane, position_opt) => {
// Close any existing context menues
let panes: Vec<_> = self.pane_model.panes.iter().collect();
for (_pane, tab_model) in panes {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.context_menu = None;
}
}
// Show the context menu on the correct pane / terminal
if let Some(tab_model) = self.pane_model.panes.get(pane) {
let entity = tab_model.active();
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
// Update context menu position
let mut terminal = terminal.lock().unwrap();
terminal.context_menu = position_opt;
}
}
// Shift focus to the pane / terminal
// with the context menu
self.pane_model.focus = pane;
return self.update_title(Some(pane));
}
Message::TabNew => self.create_and_focus_new_terminal(self.pane_model.focus),
Message::TabNext => {
if let Some(tab_model) = self.pane_model.active() {
let len = tab_model.iter().count();
// Next tab position. Wraps around to 0 (first tab) if the last tab is active.
let pos = tab_model
.position(tab_model.active())
.map(|i| (i as usize + 1) % len)
.expect("at least one tab is always open");
let entity = tab_model.iter().nth(pos);
if let Some(entity) = entity {
return self.update(Message::TabActivate(entity));
}
}
}
Message::TabPrev => {
if let Some(tab_model) = self.pane_model.active() {
let pos = tab_model
.position(tab_model.active())
.and_then(|i| (i as usize).checked_sub(1))
.unwrap_or_else(|| {
tab_model.iter().count().checked_sub(1).unwrap_or_default()
});
let entity = tab_model.iter().nth(pos);
if let Some(entity) = entity {
return self.update(Message::TabActivate(entity));
}
}
}
Message::TermEvent(pane, entity, event) => {
match event {
TermEvent::Bell => {
//TODO: audible or visible bell options?
}
TermEvent::ColorRequest(index, f) => {
if let Some(tab_model) = self.pane_model.panes.get(pane) {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
let rgb = terminal.colors()[index].unwrap_or_default();
let text = f(rgb);
terminal.input_no_scroll(text.into_bytes());
}
}
}
TermEvent::Exit => {
return self.update(Message::TabClose(Some(entity)));
}
TermEvent::PtyWrite(text) => {
if let Some(tab_model) = self.pane_model.panes.get(pane) {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
terminal.input_no_scroll(text.into_bytes());
}
}
}
TermEvent::ResetTitle => {
if let Some(tab_model) = self.pane_model.panes.get_mut(pane) {
tab_model.text_set(entity, "New Terminal");
}
return self.update_title(Some(pane));
}
TermEvent::TextAreaSizeRequest(f) => {
if let Some(tab_model) = self.pane_model.panes.get(pane) {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let terminal = terminal.lock().unwrap();
let text = f(terminal.size().into());
terminal.input_no_scroll(text.into_bytes());
}
}
}
TermEvent::Title(title) => {
if let Some(tab_model) = self.pane_model.panes.get_mut(pane) {
tab_model.text_set(entity, title);
}
return self.update_title(Some(pane));
}
TermEvent::MouseCursorDirty | TermEvent::Wakeup => {
if let Some(tab_model) = self.pane_model.panes.get(pane) {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.needs_update = true;
}
}
}
_ => {
log::warn!("TODO: {:?}", event);
}
}
}
Message::TermEventTx(term_event_tx) => {
// Check if the terminal event channel was reset
if self.term_event_tx_opt.is_some() {
// Close tabs using old terminal event channel
log::warn!("terminal event channel reset, closing tabs");
// First, close other panes
while let Some((_state, sibling)) =
self.pane_model.panes.close(self.pane_model.focus)
{
self.terminal_ids.remove(&self.pane_model.focus);
self.pane_model.focus = sibling;
}
// Next, close all tabs in the active pane
if let Some(tab_model) = self.pane_model.active_mut() {
let entities: Vec<_> = tab_model.iter().collect();
for entity in entities {
tab_model.remove(entity);
}
}
}
// Set new terminal event channel
self.term_event_tx_opt = Some(term_event_tx);
// Spawn first tab
return self.update(Message::TabNew);
}
Message::ToggleContextPage(context_page) => {
if self.context_page == context_page {
self.core.window.show_context = !self.core.window.show_context;
} else {
self.context_page = context_page;
self.core.window.show_context = true;
}
self.set_context_title(context_page.title());
}
Message::ShowAdvancedFontSettings(show) => {
self.show_advanced_font_settings = show;
}
Message::WindowClose => {
return window::close(window::Id::MAIN);
}
Message::WindowNew => match env::current_exe() {
Ok(exe) => match process::Command::new(&exe).spawn() {
Ok(_child) => {}
Err(err) => {
log::error!("failed to execute {:?}: {}", exe, err);
}
},
Err(err) => {
log::error!("failed to get current executable path: {}", err);
}
},
Message::ZoomIn => {
self.zoom_adj = self.zoom_adj.saturating_add(1);
return self.save_config();
}
Message::ZoomOut => {
self.zoom_adj = self.zoom_adj.saturating_sub(1);
return self.save_config();
}
Message::ZoomReset => {
self.zoom_adj = 0;
return self.save_config();
}
}
Command::none()
}
fn context_drawer(&self) -> Option<Element<Message>> {
if !self.core.window.show_context {
return None;
}
Some(match self.context_page {
ContextPage::Settings => self.settings(),
})
}
fn header_start(&self) -> Vec<Element<Self::Message>> {
vec![menu_bar(&self.key_binds)]
}
fn header_end(&self) -> Vec<Element<Self::Message>> {
let cosmic_theme::Spacing { space_xxs, .. } = self.core().system_theme().cosmic().spacing;
vec![widget::button(widget::icon::from_name("list-add-symbolic"))
.on_press(Message::TabNew)
.padding(space_xxs)
.style(style::Button::Icon)
.into()]
}
/// Creates a view after each update.
fn view(&self) -> Element<Self::Message> {
let cosmic_theme = self.core().system_theme().cosmic();
let cosmic_theme::Spacing { space_xxs, .. } = cosmic_theme.spacing;
{
// Update terminal window color
//TODO: do this only when theme changes?
let color = Color::from(cosmic_theme.bg_color());
let bytes = color.into_rgba8();
let data = (bytes[2] as u32)
| ((bytes[1] as u32) << 8)
| ((bytes[0] as u32) << 16)
| 0xFF000000;
if terminal::WINDOW_BG_COLOR.swap(data, Ordering::SeqCst) != data {
// If window bg color changed, update terminal colors
for (_pane, tab_model) in self.pane_model.panes.iter() {
for entity in tab_model.iter() {
if let Some(terminal) = tab_model.data::<Mutex<Terminal>>(entity) {
let mut terminal = terminal.lock().unwrap();
terminal.update_colors(&self.config);
terminal.update();
}
}
}
}
}
let pane_grid = PaneGrid::new(&self.pane_model.panes, |pane, tab_model, _is_maximized| {
let mut tab_column = widget::column::with_capacity(1);
if tab_model.iter().count() > 1 {
tab_column = tab_column.push(
widget::container(
widget::view_switcher::horizontal(tab_model)
.button_height(32)
.button_spacing(space_xxs)
.on_activate(Message::TabActivate)
.on_close(|entity| Message::TabClose(Some(entity))),
)
.style(style::Container::Background)
.width(Length::Fill),
);
}
let entity = tab_model.active();
let terminal_id = self
.terminal_ids
.get(&pane)
.cloned()
.unwrap_or_else(widget::Id::unique);
match tab_model.data::<Mutex<Terminal>>(entity) {
Some(terminal) => {
let mut terminal_box = terminal_box(terminal).id(terminal_id).on_context_menu(
move |position_opt| Message::TabContextMenu(pane, position_opt),
);
if self.config.focus_follow_mouse {
terminal_box =
terminal_box.on_mouse_enter(move || Message::MouseEnter(pane));
}
let context_menu = {
let terminal = terminal.lock().unwrap();
terminal.context_menu
};
let tab_element: Element<'_, Message> = match context_menu {
Some(position) => widget::popover(
terminal_box.context_menu(position),
menu::context_menu(&self.config, &self.key_binds, entity),
)
.position(position)
.into(),
None => terminal_box.into(),
};
tab_column = tab_column.push(tab_element);
}
None => {
//TODO
}
}
//Only draw find in the currently focused pane
if self.find && pane == self.pane_model.focus {
let find_input = widget::text_input::text_input(
fl!("find-placeholder"),
&self.find_search_value,
)
.id(self.find_search_id.clone())
.on_input(Message::FindSearchValueChanged)
// This is inverted for ease of use, usually in terminals you want to search
// upwards, which is FindPrevious
.on_submit(if self.modifiers.contains(Modifiers::SHIFT) {
Message::FindNext
} else {
Message::FindPrevious
})
.width(Length::Fixed(320.0))
.trailing_icon(
button(icon_cache_get("edit-clear-symbolic", 16))
.on_press(Message::FindSearchValueChanged(String::new()))
.style(style::Button::Icon)
.into(),
);
let find_widget = widget::row::with_children(vec![
find_input.into(),
widget::tooltip(
button(icon_cache_get("go-up-symbolic", 16))
.on_press(Message::FindPrevious)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("find-previous"),
widget::tooltip::Position::Top,
)
.into(),
widget::tooltip(
button(icon_cache_get("go-down-symbolic", 16))
.on_press(Message::FindNext)
.padding(space_xxs)
.style(style::Button::Icon),
fl!("find-next"),
widget::tooltip::Position::Top,
)
.into(),
widget::horizontal_space(Length::Fill).into(),
button(icon_cache_get("window-close-symbolic", 16))
.on_press(Message::Find(false))
.padding(space_xxs)
.style(style::Button::Icon)
.into(),
])
.align_items(Alignment::Center)
.padding(space_xxs)
.spacing(space_xxs);
tab_column = tab_column.push(
widget::cosmic_container::container(find_widget)
.layer(cosmic_theme::Layer::Primary),
);
}
pane_grid::Content::new(tab_column)
})
.width(Length::Fill)
.height(Length::Fill)
.spacing(space_xxs)
.on_click(Message::PaneClicked)
.on_resize(space_xxs, Message::PaneResized)
.on_drag(Message::PaneDragged);
container(pane_grid)
.width(Length::Fill)
.height(Length::Fill)
.padding(space_xxs)
.style(style::Container::Background)
.into()
}
fn subscription(&self) -> Subscription<Self::Message> {
struct ConfigSubscription;
struct TerminalEventSubscription;
struct ThemeSubscription;
struct ThemeModeSubscription;
Subscription::batch([
event::listen_with(|event, _status| match event {
Event::Keyboard(KeyEvent::KeyPressed { key, modifiers, .. }) => {
Some(Message::Key(modifiers, key))
}
Event::Keyboard(KeyEvent::ModifiersChanged(modifiers)) => {
Some(Message::Modifiers(modifiers))
}
_ => None,
}),
subscription::channel(
TypeId::of::<TerminalEventSubscription>(),
100,
|mut output| async move {
let (event_tx, mut event_rx) = mpsc::channel(100);
output.send(Message::TermEventTx(event_tx)).await.unwrap();
while let Some((pane, entity, event)) = event_rx.recv().await {
output
.send(Message::TermEvent(pane, entity, event))
.await
.unwrap();
}
panic!("terminal event channel closed");
},
),
cosmic_config::config_subscription(
TypeId::of::<ConfigSubscription>(),
Self::APP_ID.into(),
CONFIG_VERSION,
)
.map(|update| {
if !update.errors.is_empty() {
log::debug!(
"errors loading config {:?}: {:?}",
update.keys,
update.errors
);
}
Message::Config(update.config)
}),
cosmic_config::config_subscription::<_, cosmic_theme::Theme>(
TypeId::of::<ThemeSubscription>(),
if self.core.system_theme_mode().is_dark {
cosmic_theme::DARK_THEME_ID
} else {
cosmic_theme::LIGHT_THEME_ID
}
.into(),
cosmic_theme::Theme::version(),
)
.map(|_update| Message::SystemThemeChange),
cosmic_config::config_subscription::<_, cosmic_theme::ThemeMode>(
TypeId::of::<ThemeModeSubscription>(),
cosmic_theme::THEME_MODE_ID.into(),
cosmic_theme::ThemeMode::version(),
)
.map(|_update| Message::SystemThemeChange),
])
}
}