T16: the settings window: Cmd-, opens it or brings it forward and Cmd-W closes it, Ctrl-, and Ctrl-Shift-W off macOS; General's rows in Ely's settings layout, Shell in Ely's path input and Start in as Home or the session's folder, each change checked, saved and applied to new sessions at once; a shell saves only when it names a program, else the row says why; the app quits with its last terminal window; shot aims at either window with to:settings and to:main
This commit is contained in:
5 files changed
+321
-23
No files matched your search
+9
-3
@@ -10,7 +10,7 @@ use gpui::{
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::term::mac;
|
||||
use crate::{
|
||||
settings::SettingsStore,
|
||||
settings::{self, SettingsStore},
|
||||
term::{ICONS, Launch, MONO, config, input, shell::ShellIntegration, spawn},
|
||||
themes,
|
||||
workspace::{self, TITLE, Workspace},
|
||||
@@ -41,9 +41,15 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> {
|
||||
ShellIntegration::init(cx)?;
|
||||
input::bind_keys(cx);
|
||||
workspace::bind_keys(cx);
|
||||
settings::window::bind_keys(cx);
|
||||
// Settings alone hold no terminal, so they go with the last one.
|
||||
cx.on_window_closed(|cx, _| {
|
||||
if cx.windows().is_empty() {
|
||||
log::info!("app: no window left; quitting");
|
||||
let windows = cx.windows();
|
||||
if !windows
|
||||
.iter()
|
||||
.any(|window| window.downcast::<Workspace>().is_some())
|
||||
{
|
||||
log::info!("app: no terminal window left; quitting");
|
||||
cx.quit();
|
||||
}
|
||||
})
|
||||
|
||||
+68
-1
@@ -1,6 +1,7 @@
|
||||
mod store;
|
||||
pub mod window;
|
||||
|
||||
use std::ops::RangeInclusive;
|
||||
use std::{env, ffi::OsStr, ops::RangeInclusive, path::Path};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
@@ -129,10 +130,46 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
anyhow::ensure!(!self.font_family.is_empty(), "font_family is empty");
|
||||
anyhow::ensure!(
|
||||
self.shell.is_empty() || runs(&self.shell, &env::var_os("PATH").unwrap_or_default()),
|
||||
"shell is {:?}, which names no program",
|
||||
self.shell
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `shell` names a program a session starts: a file at a full path, or a name in one of `folders`, as `PATH` lists them.
|
||||
fn runs(shell: &str, folders: &OsStr) -> bool {
|
||||
let path = Path::new(shell);
|
||||
if path.is_absolute() {
|
||||
return program(path);
|
||||
}
|
||||
// A relative path would hang on each session's folder.
|
||||
if path.components().count() != 1 {
|
||||
return false;
|
||||
}
|
||||
env::split_paths(folders).any(|folder| {
|
||||
let file = folder.join(path);
|
||||
program(&file) || (cfg!(windows) && program(&file.with_extension("exe")))
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `path` is a file a session can run.
|
||||
fn program(path: &Path) -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let run =
|
||||
|meta: std::fs::Metadata| meta.is_file() && meta.permissions().mode() & 0o111 != 0;
|
||||
path.metadata().is_ok_and(run)
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
path.is_file()
|
||||
}
|
||||
}
|
||||
|
||||
fn within<T: PartialOrd + std::fmt::Display>(
|
||||
field: &str,
|
||||
value: T,
|
||||
@@ -175,6 +212,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shell_names_a_program_or_none() {
|
||||
let shell = |shell: &str| Settings {
|
||||
shell: shell.into(),
|
||||
..Settings::default()
|
||||
};
|
||||
assert!(shell("").validate().is_ok(), "the login shell");
|
||||
let named = if cfg!(windows) { "cmd" } else { "sh" };
|
||||
assert!(shell(named).validate().is_ok(), "a name on PATH");
|
||||
let error = shell("/no/such/shell").validate().expect_err("no file");
|
||||
let said = "shell is \"/no/such/shell\", which names no program";
|
||||
assert_eq!(error.to_string(), said);
|
||||
assert!(shell("bin/sh").validate().is_err(), "a relative path");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
assert!(runs("sh", OsStr::new("/bin")));
|
||||
let root = OsStr::new("/");
|
||||
assert!(
|
||||
!runs("bin/sh", root),
|
||||
"a relative path, though / holds bin/sh"
|
||||
);
|
||||
assert!(shell("/bin/sh").validate().is_ok());
|
||||
assert!(shell("/bin").validate().is_err(), "a folder");
|
||||
assert!(
|
||||
shell("/etc/hosts").validate().is_err(),
|
||||
"a file that does not run"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_nan_is_out_of_range() {
|
||||
let settings = Settings {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
use ely_gpui_component::{
|
||||
buttons::SegmentedControl,
|
||||
feedback::InlineMessage,
|
||||
forms::{InputEvent, PathInput, TextInput},
|
||||
primitives::{FocusScope, IconName, Severity},
|
||||
settings::{SettingsLayout, SettingsRow, SettingsSection},
|
||||
shell::{ColumnShell, ResizeBorder, ShellColumn},
|
||||
theme::{ActiveTheme, ControlSize},
|
||||
};
|
||||
use gpui::{
|
||||
App, AppContext, Bounds, Context, Entity, FocusHandle, InteractiveElement, IntoElement,
|
||||
KeyBinding, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled,
|
||||
Subscription, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions, actions,
|
||||
div, px, size,
|
||||
};
|
||||
|
||||
use super::{SettingsStore, StartIn};
|
||||
|
||||
actions!(elyterminal, [OpenSettings, CloseSettings]);
|
||||
|
||||
/// The key context of the settings window's content.
|
||||
const CONTEXT: &str = "Settings";
|
||||
|
||||
/// Each category's key, name and icon.
|
||||
const SECTIONS: [(&str, &str, IconName); 1] = [("general", "General", IconName::Settings)];
|
||||
|
||||
/// Cmd-, opens the settings, Cmd-W closes them; off macOS Ctrl-, and Ctrl-Shift-W, as Ghostty's.
|
||||
pub fn bind_keys(cx: &mut App) {
|
||||
let (open, close) = if cfg!(target_os = "macos") {
|
||||
("cmd-,", "cmd-w")
|
||||
} else {
|
||||
("ctrl-,", "ctrl-shift-w")
|
||||
};
|
||||
cx.bind_keys([
|
||||
KeyBinding::new(open, OpenSettings, None),
|
||||
KeyBinding::new(close, CloseSettings, Some(CONTEXT)),
|
||||
]);
|
||||
cx.on_action(|_: &OpenSettings, cx| open_window(cx));
|
||||
}
|
||||
|
||||
/// Opens the settings window, or brings the open one forward.
|
||||
fn open_window(cx: &mut App) {
|
||||
let open = cx
|
||||
.windows()
|
||||
.into_iter()
|
||||
.find_map(|window| window.downcast::<SettingsWindow>());
|
||||
if let Some(window) = open {
|
||||
log::info!("settings: the window comes forward");
|
||||
let forward = window.update(cx, |_, window, _| window.activate_window());
|
||||
if let Err(error) = forward {
|
||||
log::error!("settings: the window did not come forward: {error:#}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
let bounds = Bounds::centered(None, size(px(760.0), px(560.0)), cx);
|
||||
let options = WindowOptions {
|
||||
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||
titlebar: Some(TitlebarOptions {
|
||||
title: Some("Settings".into()),
|
||||
appears_transparent: true,
|
||||
traffic_light_position: Some(cx.theme().traffic_light_origin()),
|
||||
}),
|
||||
// Ely's column draws the controls; only Linux reads this.
|
||||
window_decorations: Some(WindowDecorations::Client),
|
||||
..Default::default()
|
||||
};
|
||||
match cx.open_window(options, |window, cx| {
|
||||
cx.new(|cx| SettingsWindow::new(window, cx))
|
||||
}) {
|
||||
Ok(_) => log::info!("settings: the window opens"),
|
||||
Err(error) => log::error!("settings: the window did not open: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings window: categories down the side, the open one's rows beside them.
|
||||
pub struct SettingsWindow {
|
||||
focus: FocusHandle,
|
||||
section: SharedString,
|
||||
shell: Entity<TextInput>,
|
||||
/// Why the typed shell is not saved.
|
||||
refused: Option<SharedString>,
|
||||
_typed: Subscription,
|
||||
_settings: Subscription,
|
||||
}
|
||||
|
||||
impl SettingsWindow {
|
||||
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||
let shell = cx.new(|cx| {
|
||||
let mut input = TextInput::new(window, cx).placeholder("Login shell");
|
||||
input.set_text(SettingsStore::get(cx).shell.clone(), cx);
|
||||
input
|
||||
});
|
||||
let typed = cx.subscribe(&shell, |this, input, event: &InputEvent, cx| {
|
||||
if let InputEvent::Changed = event {
|
||||
let shell = input.read(cx).text().to_string();
|
||||
this.save_shell(shell, cx);
|
||||
}
|
||||
});
|
||||
// Focus inside, so the window's keys find its context.
|
||||
let focus = cx.focus_handle();
|
||||
window.focus(&focus, cx);
|
||||
Self {
|
||||
focus,
|
||||
section: SECTIONS[0].0.into(),
|
||||
shell,
|
||||
refused: None,
|
||||
_typed: typed,
|
||||
_settings: cx.observe_global::<SettingsStore>(|_, cx| cx.notify()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Saves `shell` when it names a program; else says why and keeps the saved one.
|
||||
fn save_shell(&mut self, shell: String, cx: &mut Context<Self>) {
|
||||
let refused = match SettingsStore::update(cx, |settings| settings.shell = shell.clone()) {
|
||||
Ok(()) => {
|
||||
log::info!("settings: new sessions run {shell:?}");
|
||||
None
|
||||
}
|
||||
Err(error) => {
|
||||
log::debug!("settings: the shell stays: {error:#}");
|
||||
Some(format!("{error:#}").into())
|
||||
}
|
||||
};
|
||||
if refused != self.refused {
|
||||
self.refused = refused;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
|
||||
fn general(&self, cx: &Context<Self>) -> SettingsSection {
|
||||
let start = match SettingsStore::get(cx).start_in {
|
||||
StartIn::Home => "home",
|
||||
StartIn::Session => "session",
|
||||
};
|
||||
let shell = div()
|
||||
.w(cx.theme().prose_width())
|
||||
.flex()
|
||||
.flex_col()
|
||||
.gap_1()
|
||||
.child(PathInput::new("settings-shell", &self.shell))
|
||||
.children((self.refused.clone()).map(|why| InlineMessage::new(Severity::Danger, why)));
|
||||
let start = SegmentedControl::new("settings-start-in", start)
|
||||
.segment("home", "Home", None)
|
||||
.segment("session", "Session's folder", None)
|
||||
.size(ControlSize::Sm)
|
||||
.on_change(|value, _, cx| start_in(value, cx));
|
||||
SettingsSection::new("General")
|
||||
.row(SettingsRow::new("Shell").control(shell))
|
||||
.row(SettingsRow::new("Start in").control(start))
|
||||
}
|
||||
}
|
||||
|
||||
/// Where new sessions start, as the settings keep it.
|
||||
fn start_in(value: &SharedString, cx: &mut App) {
|
||||
let start = match value.as_ref() {
|
||||
"home" => StartIn::Home,
|
||||
"session" => StartIn::Session,
|
||||
other => unreachable!("settings: no start {other}"),
|
||||
};
|
||||
match SettingsStore::update(cx, |settings| settings.start_in = start) {
|
||||
Ok(()) => log::info!("settings: new sessions start in {start:?}"),
|
||||
Err(error) => log::error!("settings: the start stays: {error:#}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for SettingsWindow {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let pick = cx.entity();
|
||||
let layout = SettingsLayout::new("settings", SECTIONS, self.section.clone(), {
|
||||
move |key, _, cx| {
|
||||
pick.update(cx, |this, cx| {
|
||||
this.section = key.clone();
|
||||
cx.notify();
|
||||
})
|
||||
}
|
||||
})
|
||||
.page(self.general(cx));
|
||||
let body = div()
|
||||
.id("settings-body")
|
||||
.size_full()
|
||||
.overflow_y_scroll()
|
||||
.p_6()
|
||||
.child(layout);
|
||||
let shell = ColumnShell::new("settings").column(
|
||||
ShellColumn::new("settings-column")
|
||||
.title("Settings")
|
||||
.child(body),
|
||||
);
|
||||
// The context sits above the focus root, so its keys work wherever focus is.
|
||||
div()
|
||||
.size_full()
|
||||
.key_context(CONTEXT)
|
||||
.on_action(|_: &CloseSettings, window, _| {
|
||||
log::info!("settings: the window closes");
|
||||
window.remove_window();
|
||||
})
|
||||
.on_action(|_: &OpenSettings, window, _| {
|
||||
log::info!("settings: the window comes forward");
|
||||
window.activate_window();
|
||||
})
|
||||
.child(
|
||||
ResizeBorder::new()
|
||||
.child(FocusScope::new(&self.focus).root().size_full().child(shell)),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user