From 49bd07be6bdae092aa7ac7bd7c427e3e676df588 Mon Sep 17 00:00:00 2001 From: Dararaaaa Date: Tue, 3 Nov 2026 15:00:50 +0800 Subject: [PATCH 1/2] Background: a picture in the terminal or the window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Background image gains a Picture scope: the terminal's column, the default, or the window below the title bar. Settings › Appearance shows the choice beside the image. The picture paints behind the workspace from src/workspace/wallpaper.rs, over the window's ground — the theme's background at the background opacity — so the columns can leave their glass at 0 while a picture is set, and the picture keeps its colors. A canvas ruler records the terminal body's bounds, since a column's header may hold it wider than its width setting, and asks for another frame when they move; the window's picture starts below the title bar from those bounds. The old TerminalView backdrop is gone. settings.json gains background_scope (terminal or window); a file without it keeps the terminal. Tests cover the default and round trip, and both boxes. PRD, AGENTS.md, tasks/acceptance.md and the feature docs say so. --- AGENTS.md | 1 + PRD.md | 2 +- docs/en_us/features.md | 1 + docs/zh_cn/features.md | 1 + src/app.rs | 1 + src/i18n/en.rs | 5 +- src/i18n/zh.rs | 5 +- src/settings/mod.rs | 12 +++ src/settings/tests.rs | 11 +++ src/settings/window/appearance.rs | 28 ++++++- src/term/backdrop.rs | 48 ----------- src/term/mod.rs | 1 - src/term/view.rs | 5 -- src/themes/chrome.rs | 7 +- src/workspace.rs | 24 +++++- src/workspace/wallpaper.rs | 128 ++++++++++++++++++++++++++++++ tasks/acceptance.md | 2 +- 17 files changed, 217 insertions(+), 65 deletions(-) delete mode 100644 src/term/backdrop.rs create mode 100644 src/workspace/wallpaper.rs diff --git a/AGENTS.md b/AGENTS.md index b1d11b6c..0d15e794 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -98,6 +98,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: Appearance shows every built-in theme as Ely's `RadioCard` in a `SimpleGrid`, each over a small window in the theme's colors. Separate dark theme copies the theme into `dark_theme`, so turning it on changes nothing until a dark card is picked. A theme change re-dresses the chrome at once. Font family comes from Ely's `FontPicker`; size, line height and padding from its `NumberInput`, which saves a number on Enter, on leaving the field and on each step. A font saves, and a settings file starts, only when gpui loads it as the terminal draws it: gpui falls back to another font without a word and loads none without an `m`, so the terminal draws with no font but its own. The cursor preview is a prompt in the terminal's font and theme, its cursor drawn as the terminal draws it, blinking on the terminal's beat. Opacity moves by whole percents, so the saved fraction carries no float noise. A change the store refuses leaves the setting as it was, and the page says why at its top; a number field drops its draft, and the font picker shows the saved family again. The settings window opens at 900 by 640, room for three theme columns. - 2026-10-14: Terminal shows the shell and where sessions start, which a General page held until the owner dropped it, then scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, copy on select, the sessions column's drag and right press, and Ely, then every shortcut in Ely's `KeyboardShortcutsList`, read from the keymap: the bindings of each listed command that apply in the main window's terminal, matched at any depth of its context stack as gpui matches them, so the list cannot drift from the keys. The engine reads each when it needs it, so a change applies at once to every session; a smaller scrollback drops the history past it. - 2026-10-14: the main window lets the desktop through while Background opacity is under 1, blurred or clear as Background blur says, through gpui's `Blurred` (macOS's NSVisualEffectView, Windows' acrylic) or `Transparent`; at 1 it is `Opaque`. The workspace sets it as the settings change. Ely's `ColumnShell::glass`, added to Ely for this, grounds the columns in the palette's glass, which the opacity sets, so the title bar and every column let the desktop through alike; the palette's `bg` stays solid, so a dialog, the Git diff among them, hides what lies under it. The terminal paints no ground over the glass, or the two would stack; a program's own background (OSC 11) paints over it. Ely's own themes pass their palette too, Ely's colors with the glass set. The settings window stays opaque. +- 2026-11-03: Background image paints where Picture scope says: the terminal's column or the window below the title bar, never the title bar. `src/workspace/wallpaper.rs` paints the window's ground, the theme's background at the background opacity, and the picture over it at that opacity, once behind the workspace; a canvas ruler in the terminal column's body records those bounds, since a column's header may hold it wider than its width setting, and asks for another frame when they move. While a picture is set the columns' glass is 0, so the picture keeps its colors and the columns stand transparent over the ground; a program's own background (OSC 11) still paints over it. - 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair. - 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach. - 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. They are Ely's text input actions, which `forms` exports for this, and the terminal handles them too: gpui sends a menu's action to what holds focus and enables the item only where a handler waits, so one action serves the terminal, find and every field. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's, though a space under a combining mark shows a character and counts as one; copy on select takes it, and a blank grid selects nothing. diff --git a/PRD.md b/PRD.md index 52610439..48900ba7 100644 --- a/PRD.md +++ b/PRD.md @@ -129,7 +129,7 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in - Separate dark theme: a switch; when on, a second grid for dark mode. - Font family: a list of installed fonts. Fallbacks: installed families tried in order for a character the family lacks, each added, changed or removed in a list of pickers. CJK font: a family Chinese, Japanese and Korean characters draw in, with their marks and variation selectors, before Family, empty by default, cleared by Clear. Font size. Weight, 100 to 900, 400 by default, normal text's; bold sits 300 above it, up to 900. Line height. Ligatures. Antialiasing, on Windows and Linux: Grayscale, as Windows Terminal draws, by default, or Subpixel, which gpui draws only on an opaque window. - Cursor: style (block, bar, underline, hollow), blink, glide, on by default, which slides the cursor to its new cell in 90 ms, with a live preview. - - Background opacity: a slider. Background image: a picture under the terminal, at that opacity, chosen as a file. Background blur: a switch. Backdrop, on Windows 11 from build 22621: Acrylic, Mica or Mica Alt. + - Background opacity: a slider. Background image: a picture under the terminal or the window, at that opacity, chosen as a file. Picture scope: the terminal's column, or the window below the title bar. Background blur: a switch. Backdrop, on Windows 11 from build 22621: Acrylic, Mica or Mica Alt. - Padding: horizontal and vertical. - Terminal - Shell: a path; empty runs the login shell, on Windows `pwsh`, else `powershell`, else `cmd`. On Windows a session takes the registry's environment as it starts. diff --git a/docs/en_us/features.md b/docs/en_us/features.md index 22d9d814..67902120 100644 --- a/docs/en_us/features.md +++ b/docs/en_us/features.md @@ -24,3 +24,4 @@ English / [中文](../zh_cn/features.md) - Interface font and size in Settings › Appearance: any installed family, with a sample line, and 12 to 16 px for every window's chrome; the terminal keeps its own font. - On Windows, Settings › Terminal › Explorer menu adds Open in ELYTerminal to a folder's right-click menu, for you alone, and removes it; on Windows 11 it sits under Show more options. `elyterminal FOLDER` starts the first session in FOLDER on every system; cmd, which refuses a network share as its folder, says so and starts in the Windows folder. The folder may also follow `--working-directory`, `-d` or `--startingDirectory`, as xdg-terminal-exec and Windows Terminal pass it. - Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, or on Windows 11 from build 22621 Mica or Mica Alt, as Settings › Appearance › Backdrop says, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`. +- Background image: a picture at Settings › Appearance › Background image, drawn at the background's opacity where Picture scope says, under the terminal's column or under the whole window below the title bar. A column's header can hold the terminal wider than its width setting, and the picture follows the terminal itself. The picture keeps its colors: while one is set, the columns' glass is 0, so a window-wide picture shows through them. diff --git a/docs/zh_cn/features.md b/docs/zh_cn/features.md index be76601c..4106ba27 100644 --- a/docs/zh_cn/features.md +++ b/docs/zh_cn/features.md @@ -24,3 +24,4 @@ - 设置 › 外观 中的界面字体和字号:任一已安装字体,附示例行,12 到 16 px,作用于每个窗口的界面;终端保留自己的字体。 - Windows 上,设置 › 终端 › 资源管理器菜单 可为当前用户在文件夹的右键菜单中加入或移除“在 ELYTerminal 中打开”;Windows 11 中它位于“显示更多选项”下。`elyterminal FOLDER` 在各系统上都让第一个会话从 FOLDER 开始;cmd 不接受网络共享作为当前目录,会自行提示并进入 Windows 目录。文件夹也可以写在 `--working-directory`、`-d` 或 `--startingDirectory` 之后,与 xdg-terminal-exec 和 Windows Terminal 的写法相同。 - 背景不透明度和模糊:macOS 上是 NSVisualEffectView,Windows 上是 acrylic,Windows 11 build 22621 起可按设置 › 外观 › 背景材质选云母或云母 Alt,Wayland 上是 `ext_background_effect_v1` 或 `org_kde_kwin_blur`,X11 上是 `_KDE_NET_WM_BLUR_BEHIND_REGION`,KWin 会读它。模糊只在不透明度低于 100% 时出现。Hyprland 只在自己的 `decoration:blur:enabled` 打开时模糊,而 Omarchy 4.0.4 把它关掉了;要在那里打开,把 `hl.config({ decoration = { blur = { enabled = true } } })` 加到 `~/.config/hypr/looknfeel.lua`。 +- 背景图片:在设置 › 外观 › 背景图片 选一张图,按背景不透明度显示,位置由 图片范围 决定:终端列下方,或标题栏以下的整个窗口。列头可能让终端比宽度设置更宽,图片跟着终端本身。图片保持原色:设置图片时各列的玻璃为 0,整窗图片会从列中透出。 diff --git a/src/app.rs b/src/app.rs index 07212475..17f4f5dc 100644 --- a/src/app.rs +++ b/src/app.rs @@ -102,6 +102,7 @@ pub fn start(cx: &mut App) -> anyhow::Result<()> { theme, settings.custom_themes.clone(), settings.background_opacity, + !settings.background_image.is_empty(), ) }; let mut image = SettingsStore::get(cx).background_image.clone(); diff --git a/src/i18n/en.rs b/src/i18n/en.rs index 3592e204..4f307a5d 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -94,8 +94,11 @@ pub const MESSAGES: &[(&str, &str)] = &[ ("et.appearance.image", "Background image"), ( "et.appearance.image_hint", - "A picture under the terminal, at the background's opacity", + "A picture under the terminal or the window, at the background's opacity", ), + ("et.appearance.scope", "Picture scope"), + ("et.appearance.scope_terminal", "Terminal"), + ("et.appearance.scope_window", "Window"), ("et.custom.new", "New theme"), ("et.custom.name_of", "Custom {number}"), ("et.custom.name", "Name"), diff --git a/src/i18n/zh.rs b/src/i18n/zh.rs index 297b9ef4..39ac76c8 100644 --- a/src/i18n/zh.rs +++ b/src/i18n/zh.rs @@ -91,8 +91,11 @@ pub const MESSAGES: &[(&str, &str)] = &[ ("et.appearance.image", "背景图片"), ( "et.appearance.image_hint", - "终端下方的图片,按背景不透明度显示", + "终端或窗口下方的图片,按背景不透明度显示", ), + ("et.appearance.scope", "图片范围"), + ("et.appearance.scope_terminal", "终端"), + ("et.appearance.scope_window", "窗口"), ("et.custom.new", "新建主题"), ("et.custom.name_of", "自定义 {number}"), ("et.custom.name", "名称"), diff --git a/src/settings/mod.rs b/src/settings/mod.rs index 16dc237b..f4e2aa20 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -95,6 +95,15 @@ pub enum PanelTab { Files, } +/// Where the background picture paints: the terminal's column or the window below the title bar. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackgroundScope { + #[default] + Terminal, + Window, +} + /// Everything the settings window edits. A field the file leaves out takes its default. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -118,6 +127,8 @@ pub struct Settings { pub custom_themes: Vec, /// A picture under the terminal; empty for none. pub background_image: String, + /// Where the picture paints: the terminal's column or the window below the title bar. + pub background_scope: BackgroundScope, pub font_family: String, /// Families tried in order for a character Font family lacks. pub font_fallbacks: Vec, @@ -193,6 +204,7 @@ impl Default for Settings { dark_theme: None, custom_themes: Vec::new(), background_image: String::new(), + background_scope: BackgroundScope::Terminal, font_family: "JetBrains Mono".into(), font_fallbacks: Vec::new(), cjk_font: String::new(), diff --git a/src/settings/tests.rs b/src/settings/tests.rs index a74ef77c..45d2b5ca 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -361,3 +361,14 @@ fn the_interface_wears_the_system_font_at_13_px_unless_named() { ); } } + +#[test] +fn a_picture_lies_under_the_terminal_unless_the_window_is_chosen() { + let settings: Settings = serde_json::from_str("{}").expect("every field has a default"); + assert_eq!(settings.background_scope, BackgroundScope::Terminal); + let window: Settings = + serde_json::from_str(r#"{"background_scope":"window"}"#).expect("a scope"); + assert_eq!(window.background_scope, BackgroundScope::Window); + let json = serde_json::to_string(&window).expect("the settings write"); + assert!(json.contains(r#""background_scope":"window""#), "{json}"); +} diff --git a/src/settings/window/appearance.rs b/src/settings/window/appearance.rs index de08c925..b4bad8b3 100644 --- a/src/settings/window/appearance.rs +++ b/src/settings/window/appearance.rs @@ -26,8 +26,8 @@ use super::{ use crate::{ blur, settings::{ - Backdrop, FONT_SIZE, FONT_WEIGHT, LINE_HEIGHT, Language, OPACITY, PADDING, Settings, - SettingsStore, + Backdrop, BackgroundScope, FONT_SIZE, FONT_WEIGHT, LINE_HEIGHT, Language, OPACITY, PADDING, + Settings, SettingsStore, }, }; @@ -44,6 +44,20 @@ const LANGUAGES: [(Language, &str, &str); 2] = [ (Language::SimplifiedChinese, "zh-CN", "et.language.chinese"), ]; +/// Each picture scope, its key and its name's key. +const SCOPES: [(BackgroundScope, &str, &str); 2] = [ + ( + BackgroundScope::Terminal, + "terminal", + "et.appearance.scope_terminal", + ), + ( + BackgroundScope::Window, + "window", + "et.appearance.scope_window", + ), +]; + impl SettingsWindow { /// The language, theme, font, cursor and window rows. pub(super) fn appearance(&self, window: &Window, cx: &Context) -> Div { @@ -194,6 +208,16 @@ impl SettingsWindow { .description(word("et.appearance.image_hint")) .control(image), ) + .row( + SettingsRow::new(word("et.appearance.scope")).control(choice( + &this, + "background_scope", + &SCOPES, + settings.background_scope, + |settings, scope| settings.background_scope = scope, + cx, + )), + ) .row(SettingsRow::new(word("et.appearance.blur")).control(switch( &this, "background_blur", diff --git a/src/term/backdrop.rs b/src/term/backdrop.rs deleted file mode 100644 index 9e22cb83..00000000 --- a/src/term/backdrop.rs +++ /dev/null @@ -1,48 +0,0 @@ -//! The picture under the terminal, as the settings name it. - -use std::{path::Path, sync::Arc}; - -use gpui::{ - AnyElement, Context, ImageAssetLoader, IntoElement, ObjectFit, Resource, Styled, StyledImage, - Window, img, -}; - -use super::view::TerminalView; -use crate::settings::Settings; - -impl TerminalView { - /// The picture at the background's opacity; a failed load logs once. - pub(super) fn backdrop( - &mut self, - settings: &Settings, - window: &mut Window, - cx: &mut Context, - ) -> Option { - if settings.background_image.is_empty() { - return None; - } - let path: Arc = Path::new(&settings.background_image).into(); - let source = Resource::Path(path.clone()); - match window.use_asset::(&source, cx)? { - Ok(image) => Some( - img(image) - .absolute() - .size_full() - .object_fit(ObjectFit::Cover) - .opacity(settings.background_opacity) - .into_any_element(), - ), - Err(error) => { - if self.backdrop_failed.as_deref() != Some(&*path) { - log::error!( - "terminal {}: background image {}: {error}", - self.pty.pid, - path.display() - ); - self.backdrop_failed = Some(path.to_path_buf()); - } - None - } - } - } -} diff --git a/src/term/mod.rs b/src/term/mod.rs index 7d22dea4..f9b5c93a 100644 --- a/src/term/mod.rs +++ b/src/term/mod.rs @@ -1,4 +1,3 @@ -mod backdrop; mod boxes; mod click; mod clipboard; diff --git a/src/term/view.rs b/src/term/view.rs index ef20c1fb..91f58529 100644 --- a/src/term/view.rs +++ b/src/term/view.rs @@ -81,8 +81,6 @@ pub struct TerminalView { pub(super) pointer: Pointer, /// The mouse mode, as the tap follows it. pub(super) mouse: MouseMode, - /// The background picture that last failed, logged once. - pub(super) backdrop_failed: Option, /// Kitty graphics' textures. pictures: Pictures, /// Keys go as Windows' records, as ConPTY asks. @@ -169,7 +167,6 @@ impl TerminalView { target: None, pointer: Pointer::default(), mouse: MouseMode::default(), - backdrop_failed: None, pictures: Pictures::default(), #[cfg(windows)] win32: false, @@ -452,11 +449,9 @@ impl Render for TerminalView { root.text_color(ink.fg).child(format!("{error:#}")) } }; - let backdrop = self.backdrop(&settings, window, cx); div() .relative() .size_full() - .children(backdrop) .child(root) .children(scrollbar::bar(span, window, cx)) .children( diff --git a/src/themes/chrome.rs b/src/themes/chrome.rs index 90cbd286..249edfb9 100644 --- a/src/themes/chrome.rs +++ b/src/themes/chrome.rs @@ -77,7 +77,12 @@ pub fn palette(theme: &TerminalTheme, opacity: f32) -> Palette { pub fn apply(cx: &mut App) { let settings = SettingsStore::get(cx); let theme = active(settings, cx.window_appearance(), cx); - let opacity = settings.background_opacity; + // A picture grounds the window; the columns leave it as it is. + let opacity = if settings.background_image.is_empty() { + settings.background_opacity + } else { + 0.0 + }; let mode = if theme.is_dark() { Mode::Dark } else { diff --git a/src/workspace.rs b/src/workspace.rs index bc529ca7..556bf6fd 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -7,7 +7,7 @@ use ely_gpui_component::{ motion::Reorder, primitives::{FocusScope, IconName}, shell::{ColumnShell, ResizeBorder, ShellColumn}, - theme::ControlSize, + theme::{ActiveTheme, ControlSize}, typography::Caption, }; use gpui::{ @@ -33,6 +33,7 @@ mod rename; mod reorder; mod session; mod toggles; +mod wallpaper; mod widths; use toggles::column_toggle; @@ -313,7 +314,8 @@ impl Render for Workspace { self.background.fit(window); let now = Instant::now(); self.tick(now, cx); - let settings = SettingsStore::get(cx); + let settings = SettingsStore::get(cx).clone(); + let base = cx.theme().colors.bg; let workspace = cx.entity(); // It leads the first column or ends the last. let left = column_toggle( @@ -357,7 +359,13 @@ impl Render for Workspace { .map(|session| session.terminal.clone()); let mut terminal = ShellColumn::new("terminal") .title(self.title().to_string()) - .children(shown); + .child( + div() + .relative() + .size_full() + .children(shown) + .child(wallpaper::ruler()), + ); if settings.show_sessions { let home = dirs::home_dir(); let rows: Vec<(String, AnyElement)> = self @@ -487,7 +495,15 @@ impl Render for Workspace { .child( ResizeBorder::new() .when(self.background.see_through(), ResizeBorder::glass) - .child(FocusScope::new(&self.focus).root().size_full().child(shell)), + .child( + FocusScope::new(&self.focus).root().size_full().child( + div() + .relative() + .size_full() + .children(wallpaper::paint(&settings, base, window, cx)) + .child(shell), + ), + ), ) } } diff --git a/src/workspace/wallpaper.rs b/src/workspace/wallpaper.rs new file mode 100644 index 00000000..f51740c5 --- /dev/null +++ b/src/workspace/wallpaper.rs @@ -0,0 +1,128 @@ +//! The background picture, where the settings put it. + +use std::{ + cell::{Cell, RefCell}, + path::{Path, PathBuf}, + sync::Arc, +}; + +use gpui::{ + AnyElement, App, Bounds, Hsla, ImageAssetLoader, IntoElement, ObjectFit, ParentElement, Pixels, + Resource, Size, Styled, StyledImage, Window, canvas, div, img, point, px, size, +}; + +use crate::settings::{BackgroundScope, Settings}; + +thread_local! { + /// The terminal body's bounds, as the last frame laid them. + static TERMINAL: Cell>> = const { Cell::new(None) }; + /// The picture whose failure was logged last, so each frame does not log it again. + static FAILED: RefCell> = const { RefCell::new(None) }; +} + +/// The picture's bounds: the terminal body, or the window below the title bar. +fn box_of(scope: BackgroundScope, body: Bounds, viewport: Size) -> Bounds { + match scope { + BackgroundScope::Terminal => body, + BackgroundScope::Window => Bounds::new( + point(px(0.0), body.origin.y), + size(viewport.width, viewport.height - body.origin.y), + ), + } +} + +/// A canvas that records the terminal body's bounds, so the window's picture starts below the title bar. +pub(super) fn ruler() -> impl IntoElement { + canvas( + |bounds, window, _: &mut App| { + if TERMINAL.get() != Some(bounds) { + TERMINAL.set(Some(bounds)); + window.request_animation_frame(); + } + }, + |_, (), _, _| {}, + ) + .absolute() + .inset_0() +} + +/// The window's ground and the picture over it, at the background's opacity; nothing without one. +pub(super) fn paint( + settings: &Settings, + base: Hsla, + window: &mut Window, + cx: &mut App, +) -> Option { + if settings.background_image.is_empty() { + return None; + } + let ground = div() + .absolute() + .inset_0() + .opacity(settings.background_opacity) + .child(div().absolute().inset_0().bg(base)); + let Some(body) = TERMINAL.get() else { + return Some(ground.into_any_element()); + }; + let bounds = box_of(settings.background_scope, body, window.viewport_size()); + let path: Arc = Path::new(&settings.background_image).into(); + let source = Resource::Path(path.clone()); + let Some(loaded) = window.use_asset::(&source, cx) else { + return Some(ground.into_any_element()); + }; + match loaded { + Ok(image) => Some( + ground + .child( + img(image) + .absolute() + .left(bounds.origin.x) + .top(bounds.origin.y) + .w(bounds.size.width) + .h(bounds.size.height) + .object_fit(ObjectFit::Cover), + ) + .into_any_element(), + ), + Err(error) => { + let fresh = FAILED.with(|failed| { + let mut failed = failed.borrow_mut(); + if failed.as_deref() == Some(&*path) { + return false; + } + *failed = Some(path.to_path_buf()); + true + }); + if fresh { + log::error!("background: {}: {error}", path.display()); + } + Some(ground.into_any_element()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn body() -> Bounds { + Bounds::new(point(px(220.0), px(38.0)), size(px(800.0), px(600.0))) + } + + #[test] + fn the_terminal_scope_paints_the_terminal_body() { + let bounds = box_of( + BackgroundScope::Terminal, + body(), + size(px(1200.0), px(700.0)), + ); + assert_eq!(bounds, body()); + } + + #[test] + fn the_window_scope_starts_below_the_title_bar_and_spans_the_window() { + let bounds = box_of(BackgroundScope::Window, body(), size(px(1200.0), px(700.0))); + assert_eq!(bounds.origin, point(px(0.0), px(38.0))); + assert_eq!(bounds.size, size(px(1200.0), px(662.0))); + } +} diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 320ecfa7..f3d3e8e3 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -13,7 +13,7 @@ What no check reached: issue #29's physical keyboard and fcitx5's own forwarding | Silent: no sound, bell, notification but a session's or a newer release's, telemetry, tip or onboarding | T10, T12, T40, T56 | a bell is logged and marks a hidden row; the one network call is the update check the owner asked for in T56, `curl` to GitHub's `releases/latest` at start and every 24 hours a run lasts while Check for updates is on, and when the switch turns on or Check now is pressed, neither of which posts; the notifications are a session's and, at each automatic check that finds one, a newer release's, under its release's tag, through gpui's, which sets no sound on macOS and a silent toast on Windows; on Linux the notification daemon decides | Pass | | Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass | | Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass | -| The user's own themes from Ely Dark: New theme, their name and 21 colors, Delete; built-ins never change; a background image under the terminal | T109 | `themes::custom` tests: Ely Dark's copy under the next id, each slot's color, a color, a prefix or a name that does not read names the theme; `settings` tests: a custom id chosen, one gone or held twice refused, a background image a file at a full path; on macOS `shot`: New theme made and wore `custom-1`, the picker saved a foreground and the chrome wore it, the name saved at each key, Delete wore Ely Dark; a PNG drew under the text, a script named as one logged one error | Pass | +| The user's own themes from Ely Dark: New theme, their name and 21 colors, Delete; built-ins never change; a background image under the terminal or the window, as Picture scope says | T109 | `themes::custom` tests: Ely Dark's copy under the next id, each slot's color, a color, a prefix or a name that does not read names the theme; `settings` tests: a custom id chosen, one gone or held twice refused, a background image a file at a full path, its scope the terminal unless the window is chosen; on macOS `shot`: New theme made and wore `custom-1`, the picker saved a foreground and the chrome wore it, the name saved at each key, Delete wore Ely Dark; a PNG drew under the text, a script named as one logged one error | Pass | | The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial | ## 2. Foundation From 17b08af9eb3b69dd261df7f4c1c1666298284c71 Mon Sep 17 00:00:00 2001 From: Dararaaaa Date: Tue, 3 Nov 2026 15:39:06 +0800 Subject: [PATCH 2/2] Background: the picture's size, position and repeat MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picture fills its box as CSS's `background-size`, `background-position` and `background-repeat` say: `cover`, `contain`, `fill`, `auto`, or a width and a height such as `51% auto`; a place such as `right top` or `125% 100%`; and no repeat, across, down or both. Settings › Appearance gains Picture size, Picture position and Picture repeat under Picture scope, and a size or a place that does not read says why under its field. `src/settings/picture.rs` holds the model and its parsers; `wallpaper.rs` sizes the picture against its own pixels at the window's scale, places it, and tiles it from before the box, at most 400 tiles. --- AGENTS.md | 1 + PRD.md | 2 +- docs/en_us/features.md | 2 +- docs/zh_cn/features.md | 2 +- src/i18n/en.rs | 15 ++ src/i18n/zh.rs | 15 ++ src/settings/mod.rs | 11 + src/settings/picture.rs | 392 ++++++++++++++++++++++++++++++ src/settings/tests.rs | 22 ++ src/settings/window.rs | 28 +-- src/settings/window/appearance.rs | 51 +--- src/settings/window/picture.rs | 219 +++++++++++++++++ src/workspace/wallpaper.rs | 195 ++++++++++++--- tasks/acceptance.md | 2 +- 14 files changed, 857 insertions(+), 100 deletions(-) create mode 100644 src/settings/picture.rs create mode 100644 src/settings/window/picture.rs diff --git a/AGENTS.md b/AGENTS.md index 0d15e794..d90e0179 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,7 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-14: Terminal shows the shell and where sessions start, which a General page held until the owner dropped it, then scrollback lines, Option as Alt, on macOS only, since elsewhere Alt is Alt, copy on select, the sessions column's drag and right press, and Ely, then every shortcut in Ely's `KeyboardShortcutsList`, read from the keymap: the bindings of each listed command that apply in the main window's terminal, matched at any depth of its context stack as gpui matches them, so the list cannot drift from the keys. The engine reads each when it needs it, so a change applies at once to every session; a smaller scrollback drops the history past it. - 2026-10-14: the main window lets the desktop through while Background opacity is under 1, blurred or clear as Background blur says, through gpui's `Blurred` (macOS's NSVisualEffectView, Windows' acrylic) or `Transparent`; at 1 it is `Opaque`. The workspace sets it as the settings change. Ely's `ColumnShell::glass`, added to Ely for this, grounds the columns in the palette's glass, which the opacity sets, so the title bar and every column let the desktop through alike; the palette's `bg` stays solid, so a dialog, the Git diff among them, hides what lies under it. The terminal paints no ground over the glass, or the two would stack; a program's own background (OSC 11) paints over it. Ely's own themes pass their palette too, Ely's colors with the glass set. The settings window stays opaque. - 2026-11-03: Background image paints where Picture scope says: the terminal's column or the window below the title bar, never the title bar. `src/workspace/wallpaper.rs` paints the window's ground, the theme's background at the background opacity, and the picture over it at that opacity, once behind the workspace; a canvas ruler in the terminal column's body records those bounds, since a column's header may hold it wider than its width setting, and asks for another frame when they move. While a picture is set the columns' glass is 0, so the picture keeps its colors and the columns stand transparent over the ground; a program's own background (OSC 11) still paints over it. +- 2026-11-03: the picture's size, position and repeat are CSS's, kept as strings and read by `src/settings/picture.rs`: `PictureSize` (cover, contain, fill, auto, or a width and a height), `PictureLength` (auto, pixels, a percentage of 0 to 100), `PicturePosition` with a `PictureAxis` for each side, and `PictureRepeat` (no repeat, across, down, both). A size of `51%` prints `51% auto`, its height kept as written; a place of `right top` prints as written. `src/workspace/wallpaper.rs` sizes the picture against the image's own pixels at the window's scale, places it, and tiles it from before its box, at most 400 tiles, and a picture past that draws once and logs. The rows sit in Settings › Appearance › Background, after Picture scope; a size or a place that does not read says why under its field. - 2026-10-15: on Wayland the window asks for `ext_background_effect_v1` beside gpui's `org_kde_kwin_blur`. A guest connection on gpui's own display binds it, takes the effect for the window's surface once, and blurs the window's pane: the surface less Ely's resize margin on each free edge of a client-drawn window, worked out each frame and sent only when it changes; it goes and comes back with Blurred and lands at the surface's next commit, which every frame makes. The guest connection reads the display only while the window opens, before gpui's loop runs, and is never dropped: a drop destroys its proxies through the display, which gpui may close first. The log names what the compositor offers, so which way blur goes: `ext_background_effect_v1`, gpui's `org_kde_kwin_blur`, or none, the desktop clear. Its test runs a `wayland-server` compositor on a socket pair. - 2026-10-15: on a client-drawn window, so on Linux, Ely's `ResizeBorder` paints its pane solid and shadowed under the content, which kept the desktop from showing at all. `ResizeBorder::glass`, added to Ely for this, leaves the pane to the content while the window is see-through: no ground, nor the shadow gpui draws under the box, which would tint it. So the blur covers the pane alone and the resize margin stays clear. On X11 the window names its pane in `_KDE_NET_WM_BLUR_BEHIND_REGION` (CARDINAL, 32: x, y, width, height in the window's pixels), as Ghostty names its own, and drops the property when Blurred goes; KWin reads it, other compositors pass it by. It goes over a connection of ELYTerminal's own to `$DISPLAY`, which gpui opens too: a property belongs to the window, whoever sets it. gpui's own `org_kde_kwin_blur` on KDE's Wayland still covers the whole surface, margin included, beyond ELYTerminal's reach. - 2026-10-15: the macOS menu bar is the PRD's: ELYTerminal (Settings…, Quit), Shell (New Session, Close Session), Edit (Copy, Paste, Select All, Find), View (Toggle Sessions, Toggle Right Panel, Bigger, Smaller, Reset Size), Window (Minimize, Zoom, then the windows macOS lists). An item shows the key bound to its action, so the menus are set after the bindings; Copy, Paste and Select All go through AppKit's `copy:`, `paste:` and `selectAll:`, as gpui's `OsAction` maps them. They are Ely's text input actions, which `forms` exports for this, and the terminal handles them too: gpui sends a menu's action to what holds focus and enables the item only where a handler waits, so one action serves the terminal, find and every field. The new keys are Ghostty's: Cmd-Q, Cmd-A, Cmd-= and Cmd-+, Cmd-minus, Cmd-0, Cmd-M. Off macOS Quit and Select All take Ctrl-Shift, since Ctrl with a letter types a control byte; the font's size takes plain Ctrl with =, +, minus and 0, which type none, as in Ghostty, Alacritty and Windows Terminal; Minimize has no key there. Bigger and Smaller move every session's font a point, inside the Size range, and Reset Size goes back to the setting; nothing saves the change, as in Ghostty. Select All runs from the first character in the history to the last on the screen, the blanks around them left out, as Ghostty's, though a space under a combining mark shows a character and counts as one; copy on select takes it, and a blank grid selects nothing. diff --git a/PRD.md b/PRD.md index 48900ba7..eb94ef44 100644 --- a/PRD.md +++ b/PRD.md @@ -129,7 +129,7 @@ Cmd-, opens it; on Windows and Linux, which have no menu bar, so does a gear in - Separate dark theme: a switch; when on, a second grid for dark mode. - Font family: a list of installed fonts. Fallbacks: installed families tried in order for a character the family lacks, each added, changed or removed in a list of pickers. CJK font: a family Chinese, Japanese and Korean characters draw in, with their marks and variation selectors, before Family, empty by default, cleared by Clear. Font size. Weight, 100 to 900, 400 by default, normal text's; bold sits 300 above it, up to 900. Line height. Ligatures. Antialiasing, on Windows and Linux: Grayscale, as Windows Terminal draws, by default, or Subpixel, which gpui draws only on an opaque window. - Cursor: style (block, bar, underline, hollow), blink, glide, on by default, which slides the cursor to its new cell in 90 ms, with a live preview. - - Background opacity: a slider. Background image: a picture under the terminal or the window, at that opacity, chosen as a file. Picture scope: the terminal's column, or the window below the title bar. Background blur: a switch. Backdrop, on Windows 11 from build 22621: Acrylic, Mica or Mica Alt. + - Background opacity: a slider. Background image: a picture under the terminal or the window, at that opacity, chosen as a file. Picture scope: the terminal's column, or the window below the title bar. Picture size: cover, contain, fill, auto, or a width and a height such as `51% auto`. Picture position: a place such as `right top` or `125% 100%`. Picture repeat: no repeat, across, down or both. Background blur: a switch. Backdrop, on Windows 11 from build 22621: Acrylic, Mica or Mica Alt. - Padding: horizontal and vertical. - Terminal - Shell: a path; empty runs the login shell, on Windows `pwsh`, else `powershell`, else `cmd`. On Windows a session takes the registry's environment as it starts. diff --git a/docs/en_us/features.md b/docs/en_us/features.md index 67902120..284e9cc2 100644 --- a/docs/en_us/features.md +++ b/docs/en_us/features.md @@ -24,4 +24,4 @@ English / [中文](../zh_cn/features.md) - Interface font and size in Settings › Appearance: any installed family, with a sample line, and 12 to 16 px for every window's chrome; the terminal keeps its own font. - On Windows, Settings › Terminal › Explorer menu adds Open in ELYTerminal to a folder's right-click menu, for you alone, and removes it; on Windows 11 it sits under Show more options. `elyterminal FOLDER` starts the first session in FOLDER on every system; cmd, which refuses a network share as its folder, says so and starts in the Windows folder. The folder may also follow `--working-directory`, `-d` or `--startingDirectory`, as xdg-terminal-exec and Windows Terminal pass it. - Background opacity and blur: NSVisualEffectView on macOS, acrylic on Windows, or on Windows 11 from build 22621 Mica or Mica Alt, as Settings › Appearance › Backdrop says, `ext_background_effect_v1` or `org_kde_kwin_blur` on Wayland, `_KDE_NET_WM_BLUR_BEHIND_REGION` on X11, which KWin reads. Blur shows only while the opacity is under 100%. Hyprland blurs only while its own `decoration:blur:enabled` is on, and Omarchy 4.0.4 turns it off; to turn it on there, add `hl.config({ decoration = { blur = { enabled = true } } })` to `~/.config/hypr/looknfeel.lua`. -- Background image: a picture at Settings › Appearance › Background image, drawn at the background's opacity where Picture scope says, under the terminal's column or under the whole window below the title bar. A column's header can hold the terminal wider than its width setting, and the picture follows the terminal itself. The picture keeps its colors: while one is set, the columns' glass is 0, so a window-wide picture shows through them. +- Background image: a picture at Settings › Appearance › Background image, drawn at the background's opacity where Picture scope says, under the terminal's column or under the whole window below the title bar. A column's header can hold the terminal wider than its width setting, and the picture follows the terminal itself. The picture keeps its colors: while one is set, the columns' glass is 0, so a window-wide picture shows through them. Its size, position and repeat are CSS's: `cover`, `contain`, `fill`, `auto` or a width and a height such as `51% auto`; a place such as `right top` or `125% 100%`; and no repeat, repeat across, down or both. diff --git a/docs/zh_cn/features.md b/docs/zh_cn/features.md index 4106ba27..227e570f 100644 --- a/docs/zh_cn/features.md +++ b/docs/zh_cn/features.md @@ -24,4 +24,4 @@ - 设置 › 外观 中的界面字体和字号:任一已安装字体,附示例行,12 到 16 px,作用于每个窗口的界面;终端保留自己的字体。 - Windows 上,设置 › 终端 › 资源管理器菜单 可为当前用户在文件夹的右键菜单中加入或移除“在 ELYTerminal 中打开”;Windows 11 中它位于“显示更多选项”下。`elyterminal FOLDER` 在各系统上都让第一个会话从 FOLDER 开始;cmd 不接受网络共享作为当前目录,会自行提示并进入 Windows 目录。文件夹也可以写在 `--working-directory`、`-d` 或 `--startingDirectory` 之后,与 xdg-terminal-exec 和 Windows Terminal 的写法相同。 - 背景不透明度和模糊:macOS 上是 NSVisualEffectView,Windows 上是 acrylic,Windows 11 build 22621 起可按设置 › 外观 › 背景材质选云母或云母 Alt,Wayland 上是 `ext_background_effect_v1` 或 `org_kde_kwin_blur`,X11 上是 `_KDE_NET_WM_BLUR_BEHIND_REGION`,KWin 会读它。模糊只在不透明度低于 100% 时出现。Hyprland 只在自己的 `decoration:blur:enabled` 打开时模糊,而 Omarchy 4.0.4 把它关掉了;要在那里打开,把 `hl.config({ decoration = { blur = { enabled = true } } })` 加到 `~/.config/hypr/looknfeel.lua`。 -- 背景图片:在设置 › 外观 › 背景图片 选一张图,按背景不透明度显示,位置由 图片范围 决定:终端列下方,或标题栏以下的整个窗口。列头可能让终端比宽度设置更宽,图片跟着终端本身。图片保持原色:设置图片时各列的玻璃为 0,整窗图片会从列中透出。 +- 背景图片:在设置 › 外观 › 背景图片 选一张图,按背景不透明度显示,位置由 图片范围 决定:终端列下方,或标题栏以下的整个窗口。列头可能让终端比宽度设置更宽,图片跟着终端本身。图片保持原色:设置图片时各列的玻璃为 0,整窗图片会从列中透出。尺寸、位置和重复与 CSS 相同:`cover`、`contain`、`fill`、`auto`,或宽高如 `51% auto`;位置如 `right top` 或 `125% 100%`;重复可选不重复、横向、纵向或两者。 diff --git a/src/i18n/en.rs b/src/i18n/en.rs index 4f307a5d..2d90c2b9 100644 --- a/src/i18n/en.rs +++ b/src/i18n/en.rs @@ -99,6 +99,21 @@ pub const MESSAGES: &[(&str, &str)] = &[ ("et.appearance.scope", "Picture scope"), ("et.appearance.scope_terminal", "Terminal"), ("et.appearance.scope_window", "Window"), + ("et.appearance.picture_size", "Picture size"), + ( + "et.appearance.picture_size_hint", + "Cover, contain, fill, auto, or a width and a height: a percentage, pixels or auto", + ), + ("et.appearance.picture_position", "Picture position"), + ( + "et.appearance.picture_position_hint", + "Where it sits: a side such as right top, or an offset such as 125% 100%", + ), + ("et.appearance.picture_repeat", "Picture repeat"), + ("et.appearance.picture_repeat_no_repeat", "No repeat"), + ("et.appearance.picture_repeat_repeat", "Repeat"), + ("et.appearance.picture_repeat_x", "Repeat across"), + ("et.appearance.picture_repeat_y", "Repeat down"), ("et.custom.new", "New theme"), ("et.custom.name_of", "Custom {number}"), ("et.custom.name", "Name"), diff --git a/src/i18n/zh.rs b/src/i18n/zh.rs index 39ac76c8..ddda0e5f 100644 --- a/src/i18n/zh.rs +++ b/src/i18n/zh.rs @@ -96,6 +96,21 @@ pub const MESSAGES: &[(&str, &str)] = &[ ("et.appearance.scope", "图片范围"), ("et.appearance.scope_terminal", "终端"), ("et.appearance.scope_window", "窗口"), + ("et.appearance.picture_size", "图片尺寸"), + ( + "et.appearance.picture_size_hint", + "cover、contain、fill、auto,或宽度与高度:百分比、像素或 auto", + ), + ("et.appearance.picture_position", "图片位置"), + ( + "et.appearance.picture_position_hint", + "图片所在:right top 这样的边,或 125% 100% 这样的偏移", + ), + ("et.appearance.picture_repeat", "图片重复"), + ("et.appearance.picture_repeat_no_repeat", "不重复"), + ("et.appearance.picture_repeat_repeat", "重复"), + ("et.appearance.picture_repeat_x", "横向重复"), + ("et.appearance.picture_repeat_y", "纵向重复"), ("et.custom.new", "新建主题"), ("et.custom.name_of", "自定义 {number}"), ("et.custom.name", "名称"), diff --git a/src/settings/mod.rs b/src/settings/mod.rs index f4e2aa20..02f7968d 100644 --- a/src/settings/mod.rs +++ b/src/settings/mod.rs @@ -1,3 +1,4 @@ +mod picture; mod profiles; mod store; pub mod window; @@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::themes; +pub use picture::{PictureAxis, PictureLength, PicturePosition, PictureRepeat, PictureSize}; pub use profiles::{Profile, command_line, words}; pub use store::{SettingsStore, load, save}; @@ -129,6 +131,12 @@ pub struct Settings { pub background_image: String, /// Where the picture paints: the terminal's column or the window below the title bar. pub background_scope: BackgroundScope, + /// How the picture fills its box, as CSS's `background-size`. + pub background_size: PictureSize, + /// Where the picture sits in its box, as CSS's `background-position`. + pub background_position: PicturePosition, + /// Whether the picture tiles to fill its box. + pub background_repeat: PictureRepeat, pub font_family: String, /// Families tried in order for a character Font family lacks. pub font_fallbacks: Vec, @@ -205,6 +213,9 @@ impl Default for Settings { custom_themes: Vec::new(), background_image: String::new(), background_scope: BackgroundScope::Terminal, + background_size: PictureSize::Cover, + background_position: PicturePosition::default(), + background_repeat: PictureRepeat::NoRepeat, font_family: "JetBrains Mono".into(), font_fallbacks: Vec::new(), cjk_font: String::new(), diff --git a/src/settings/picture.rs b/src/settings/picture.rs new file mode 100644 index 00000000..0a1de112 --- /dev/null +++ b/src/settings/picture.rs @@ -0,0 +1,392 @@ +//! How a background picture fills its box: its size, its position and its repeat. + +use std::{fmt, str::FromStr}; + +use anyhow::{Context as _, bail, ensure}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; + +/// How a picture fills its box, as CSS's `background-size`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PictureSize { + /// Scaled to cover the box, the parts past it cut off. + #[default] + Cover, + /// Scaled to fit inside the box. + Contain, + /// Stretched to the box. + Fill, + /// Its own size in points. + Auto, + /// A width and a height, each a length or `auto`. + Explicit(PictureLength, PictureLength), +} + +impl PictureSize { + /// The box `intrinsic` points of picture take in `container`, in points. + pub fn points(self, intrinsic: (f32, f32), container: (f32, f32)) -> (f32, f32) { + let (width, height) = intrinsic; + match self { + Self::Cover => { + let scale = (container.0 / width).max(container.1 / height); + (width * scale, height * scale) + } + Self::Contain => { + let scale = (container.0 / width).min(container.1 / height); + (width * scale, height * scale) + } + Self::Fill => container, + Self::Auto => intrinsic, + Self::Explicit(width_length, height_length) => { + match ( + width_length.points(container.0), + height_length.points(container.1), + ) { + (Some(width), Some(height)) => (width, height), + (Some(width), None) => (width, height * width / intrinsic.0), + (None, Some(height)) => (width * height / intrinsic.1, height), + (None, None) => intrinsic, + } + } + } + } +} + +impl fmt::Display for PictureSize { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cover => f.write_str("cover"), + Self::Contain => f.write_str("contain"), + Self::Fill => f.write_str("fill"), + Self::Auto => f.write_str("auto"), + Self::Explicit(width, height) => write!(f, "{width} {height}"), + } + } +} + +impl FromStr for PictureSize { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result { + let words: Vec<&str> = text.split_whitespace().collect(); + ensure!(!words.is_empty(), "size is empty"); + ensure!(words.len() <= 2, "size is {text:?}, more than two lengths"); + Ok(match words.as_slice() { + ["cover"] => Self::Cover, + ["contain"] => Self::Contain, + ["fill"] => Self::Fill, + ["auto"] => Self::Auto, + [width, height] => Self::Explicit(width.parse()?, height.parse()?), + [width] => Self::Explicit(width.parse()?, PictureLength::Auto), + _ => unreachable!("at most two words"), + }) + } +} + +impl Serialize for PictureSize { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PictureSize { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(de::Error::custom) + } +} + +/// One side of a picture's size: points, a percentage of the box, or `auto`. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum PictureLength { + /// The picture's own side. + Auto, + /// Points. + Px(f32), + /// A percentage of the box, 0 to 100. + Percent(f32), +} + +impl PictureLength { + /// Its points in `extent`; `None` for `auto`. + pub fn points(self, extent: f32) -> Option { + match self { + Self::Auto => None, + Self::Px(points) => Some(points), + Self::Percent(percent) => Some(extent * percent / 100.0), + } + } +} + +impl fmt::Display for PictureLength { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Auto => f.write_str("auto"), + Self::Px(points) => write!(f, "{points}px"), + Self::Percent(percent) => write!(f, "{percent}%"), + } + } +} + +impl FromStr for PictureLength { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result { + let text = text.trim(); + if text == "auto" { + return Ok(Self::Auto); + } + let (number, percent) = match text.strip_suffix('%') { + Some(number) => (number, true), + None => (text.strip_suffix("px").unwrap_or(text), false), + }; + let value: f32 = number + .parse() + .with_context(|| format!("length is {text:?}"))?; + ensure!( + value.is_finite() && value >= 0.0, + "length is {text:?}, not a size" + ); + Ok(if percent { + Self::Percent(value) + } else { + Self::Px(value) + }) + } +} + +/// One side of a picture's place in its box. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub enum PictureAxis { + /// Centered in the free room. + #[default] + Center, + /// The box's start: its left or top. + Start, + /// The box's end: its right or bottom. + End, + /// A percentage of the free room, 0 to 100. + Percent(f32), + /// Points from the box's start. + Px(f32), +} + +impl PictureAxis { + /// Its offset into `free` points of room. + pub fn points(self, free: f32) -> f32 { + match self { + Self::Center => free / 2.0, + Self::Start => 0.0, + Self::End => free, + Self::Percent(percent) => free * percent / 100.0, + Self::Px(points) => points, + } + } +} + +/// Where a picture sits in its box, as CSS's `background-position`. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PicturePosition { + /// Its horizontal place. + pub x: PictureAxis, + /// Its vertical place. + pub y: PictureAxis, +} + +impl PicturePosition { + /// Its offsets into `free` points of room, across and down. + pub fn points(self, free: (f32, f32)) -> (f32, f32) { + (self.x.points(free.0), self.y.points(free.1)) + } +} + +impl fmt::Display for PicturePosition { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if (self.x, self.y) == (PictureAxis::Center, PictureAxis::Center) { + return f.write_str("center"); + } + write!( + f, + "{} {}", + side(self.x, "left", "right"), + side(self.y, "top", "bottom") + ) + } +} + +/// `axis` spelled as CSS names a side. +fn side(axis: PictureAxis, start: &str, end: &str) -> String { + match axis { + PictureAxis::Center => "center".to_owned(), + PictureAxis::Start => start.to_owned(), + PictureAxis::End => end.to_owned(), + PictureAxis::Percent(percent) => format!("{percent}%"), + PictureAxis::Px(points) => format!("{points}px"), + } +} + +impl FromStr for PicturePosition { + type Err = anyhow::Error; + + fn from_str(text: &str) -> Result { + let words: Vec<&str> = text.split_whitespace().collect(); + ensure!(!words.is_empty(), "position is empty"); + ensure!( + words.len() <= 2, + "position is {text:?}, more than two places" + ); + let (mut x, mut y) = (None, None); + for word in words { + match word { + "left" => x = Some(PictureAxis::Start), + "right" => x = Some(PictureAxis::End), + "top" => y = Some(PictureAxis::Start), + "bottom" => y = Some(PictureAxis::End), + "center" => {} + length => { + let axis = match length.parse::()? { + PictureLength::Auto => bail!("position is {text:?}, auto places nothing"), + PictureLength::Px(points) => PictureAxis::Px(points), + PictureLength::Percent(percent) => PictureAxis::Percent(percent), + }; + if x.is_none() { + x = Some(axis); + } else if y.is_none() { + y = Some(axis); + } else { + bail!("position is {text:?}, more than two places"); + } + } + } + } + Ok(Self { + x: x.unwrap_or_default(), + y: y.unwrap_or_default(), + }) + } +} + +impl Serialize for PicturePosition { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PicturePosition { + fn deserialize>(deserializer: D) -> Result { + String::deserialize(deserializer)? + .parse() + .map_err(de::Error::custom) + } +} + +/// Whether a picture repeats to fill its box. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PictureRepeat { + /// Drawn once. + #[default] + NoRepeat, + /// Tiled across and down. + Repeat, + /// Tiled across. + RepeatX, + /// Tiled down. + RepeatY, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_size_keeps_each_side_as_written() { + for text in [ + "cover", + "contain", + "fill", + "auto", + "100% 100%", + "auto 30px", + "12px auto", + ] { + let size: PictureSize = text.parse().expect(text); + assert_eq!(size.to_string(), text); + } + assert_eq!( + "51%".parse::().expect("a size").to_string(), + "51% auto" + ); + assert_eq!( + "51%".parse::().expect("a size"), + PictureSize::Explicit(PictureLength::Percent(51.0), PictureLength::Auto) + ); + } + + #[test] + fn a_size_refuses_what_is_not_one() { + for text in ["", "cover cover", "-5px", "wide", "10px 20px 30px"] { + assert!(text.parse::().is_err(), "{text:?}"); + } + } + + #[test] + fn a_side_keeps_its_points_and_percentages() { + assert_eq!( + PictureSize::Cover.points((200.0, 100.0), (100.0, 100.0)), + (200.0, 100.0) + ); + assert_eq!( + PictureSize::Contain.points((200.0, 100.0), (100.0, 100.0)), + (100.0, 50.0) + ); + assert_eq!( + PictureSize::Fill.points((200.0, 100.0), (100.0, 50.0)), + (100.0, 50.0) + ); + assert_eq!( + PictureSize::Auto.points((200.0, 100.0), (100.0, 100.0)), + (200.0, 100.0) + ); + let half: PictureSize = "50%".parse().expect("a size"); + assert_eq!(half.points((200.0, 100.0), (100.0, 100.0)), (50.0, 25.0)); + } + + #[test] + fn a_position_keeps_each_side_as_written() { + for text in [ + "center", + "left top", + "right bottom", + "125% 100%", + "10px center", + ] { + let position: PicturePosition = text.parse().expect(text); + assert_eq!(position.to_string(), text); + } + assert_eq!( + "right top".parse::().expect("a place"), + PicturePosition { + x: PictureAxis::End, + y: PictureAxis::Start + } + ); + } + + #[test] + fn a_position_refuses_what_is_not_one() { + for text in ["", "auto", "left right top"] { + assert!(text.parse::().is_err(), "{text:?}"); + } + } + + #[test] + fn a_place_keeps_its_offset() { + let center = PicturePosition::default(); + assert_eq!(center.points((100.0, 50.0)), (50.0, 25.0)); + let end: PicturePosition = "100% 100%".parse().expect("a place"); + assert_eq!(end.points((100.0, 50.0)), (100.0, 50.0)); + let start: PicturePosition = "left top".parse().expect("a place"); + assert_eq!(start.points((100.0, 50.0)), (0.0, 0.0)); + } +} diff --git a/src/settings/tests.rs b/src/settings/tests.rs index 45d2b5ca..3a7d1b09 100644 --- a/src/settings/tests.rs +++ b/src/settings/tests.rs @@ -372,3 +372,25 @@ fn a_picture_lies_under_the_terminal_unless_the_window_is_chosen() { let json = serde_json::to_string(&window).expect("the settings write"); assert!(json.contains(r#""background_scope":"window""#), "{json}"); } + +#[test] +fn a_picture_covers_its_box_unless_the_settings_say_more() { + let settings: Settings = serde_json::from_str("{}").expect("every field has a default"); + assert_eq!(settings.background_size, PictureSize::Cover); + assert_eq!(settings.background_position, PicturePosition::default()); + assert_eq!(settings.background_repeat, PictureRepeat::NoRepeat); + let sized: Settings = serde_json::from_str( + r#"{"background_size":"51% auto","background_position":"125% 100%","background_repeat":"repeat_x"}"#, + ) + .expect("a size, a position and a repeat"); + assert_eq!(sized.background_size.to_string(), "51% auto"); + assert_eq!(sized.background_position.to_string(), "125% 100%"); + assert_eq!(sized.background_repeat, PictureRepeat::RepeatX); + let json = serde_json::to_string(&sized).expect("the settings write"); + assert!(json.contains(r#""background_size":"51% auto""#), "{json}"); + assert!( + json.contains(r#""background_position":"125% 100%""#), + "{json}" + ); + assert!(json.contains(r#""background_repeat":"repeat_x""#), "{json}"); +} diff --git a/src/settings/window.rs b/src/settings/window.rs index 08fd5d2c..bb74cb7f 100644 --- a/src/settings/window.rs +++ b/src/settings/window.rs @@ -23,6 +23,7 @@ use crate::{app::APP_ID, menus, term::BLINK, updates::Updates}; mod appearance; #[cfg(windows)] mod explorer; +mod picture; mod terminal; actions!(elyterminal, [OpenSettings, CloseSettings]); @@ -97,9 +98,8 @@ pub struct SettingsWindow { /// Why the typed shell and folder are not saved. refused: Option, refused_folder: Option, - /// The background picture, typed or browsed, and why it is not saved. - image: Entity, - refused_image: Option, + /// The background picture's fields: its file, scope, size, position and repeat. + picture: picture::Fields, /// Why the last change did not save. failed: Option, /// Refusals so far; a field keyed by it drops its draft and shows the settings again. @@ -119,7 +119,7 @@ pub struct SettingsWindow { theme_name: Entity, _named: Subscription, slot: usize, - _typed: [Subscription; 3], + _typed: [Subscription; 2], _settings: Subscription, _updates: Subscription, _language: Subscription, @@ -143,11 +143,6 @@ impl SettingsWindow { let cjk_font = cx.new(|cx| TextInput::new(window, cx)); let interface_font = cx.new(|cx| TextInput::new(window, cx)); let (theme_name, named) = Self::name_field(None, String::new(), window, cx); - let image = cx.new(|cx| { - let mut input = TextInput::new(window, cx); - input.set_text(SettingsStore::get(cx).background_image.clone(), cx); - input - }); let search = cx.new(|cx| { let placeholder = text(cx, "et.settings.search_shortcuts", &[]); TextInput::new(window, cx).placeholder(placeholder) @@ -167,14 +162,8 @@ impl SettingsWindow { this.refused_folder = this.save_typed("start_folder", folder, put, cx); } }), - cx.subscribe(&image, |this, input, event: &InputEvent, cx| { - if let InputEvent::Changed = event { - let image = input.read(cx).text().to_string(); - let put = |settings: &mut Settings, image| settings.background_image = image; - this.refused_image = this.save_typed("background_image", image, put, cx); - } - }), ]; + let picture = picture::Fields::new(window, cx); let profiles = terminal::profiles::fields(window, cx); // Focus inside, so the window's keys find its context. let focus = cx.focus_handle(); @@ -187,8 +176,7 @@ impl SettingsWindow { search, refused: None, refused_folder: None, - image, - refused_image: None, + picture, failed: None, drafts: 0, font, @@ -246,7 +234,7 @@ impl SettingsWindow { let (input, refused) = match field { "shell" => (self.shell.clone(), &mut self.refused), "start_folder" => (self.folder.clone(), &mut self.refused_folder), - "background_image" => (self.image.clone(), &mut self.refused_image), + "background_image" => (self.picture.image.clone(), &mut self.picture.refused_image), other => unreachable!("settings: no path field {other}"), }; let Some(text) = path.to_str() else { @@ -266,7 +254,7 @@ impl SettingsWindow { &mut self, field: &str, value: String, - put: fn(&mut Settings, String), + put: impl FnOnce(&mut Settings, String), cx: &mut Context, ) -> Option { let saved = SettingsStore::update(cx, |settings| put(settings, value.clone())); diff --git a/src/settings/window/appearance.rs b/src/settings/window/appearance.rs index b4bad8b3..4656d504 100644 --- a/src/settings/window/appearance.rs +++ b/src/settings/window/appearance.rs @@ -8,7 +8,7 @@ use std::ops::RangeInclusive; use ely_gpui_component::{ buttons::{Button, ButtonVariant, IconButton}, - forms::{FontPicker, NumberInput, PathInput, Slider, Switch, TextInput}, + forms::{FontPicker, NumberInput, Slider, Switch, TextInput}, i18n::text, primitives::IconName, settings::{SettingsRow, SettingsSection}, @@ -19,15 +19,12 @@ use gpui::{ }; use self::cards::grid; -use super::{ - SettingsWindow, choice, set, switch, - terminal::{chosen, path_field}, -}; +use super::{SettingsWindow, choice, set, switch}; use crate::{ blur, settings::{ - Backdrop, BackgroundScope, FONT_SIZE, FONT_WEIGHT, LINE_HEIGHT, Language, OPACITY, PADDING, - Settings, SettingsStore, + Backdrop, FONT_SIZE, FONT_WEIGHT, LINE_HEIGHT, Language, OPACITY, PADDING, Settings, + SettingsStore, }, }; @@ -44,20 +41,6 @@ const LANGUAGES: [(Language, &str, &str); 2] = [ (Language::SimplifiedChinese, "zh-CN", "et.language.chinese"), ]; -/// Each picture scope, its key and its name's key. -const SCOPES: [(BackgroundScope, &str, &str); 2] = [ - ( - BackgroundScope::Terminal, - "terminal", - "et.appearance.scope_terminal", - ), - ( - BackgroundScope::Window, - "window", - "et.appearance.scope_window", - ), -]; - impl SettingsWindow { /// The language, theme, font, cursor and window rows. pub(super) fn appearance(&self, window: &Window, cx: &Context) -> Div { @@ -198,27 +181,13 @@ impl SettingsWindow { .text_size(theme.text_size(TextSize::Sm)) .child(format!("{opacity}%")), ); - let image = PathInput::new("settings-background-image", &self.image) - .on_choose(chosen(&this, "background_image")); - let image = path_field(image, self.refused_image.clone(), cx); let mut background = SettingsSection::new(word("et.appearance.window")) - .row(SettingsRow::new(word("et.appearance.opacity")).control(opacity)) - .row( - SettingsRow::new(word("et.appearance.image")) - .description(word("et.appearance.image_hint")) - .control(image), - ) - .row( - SettingsRow::new(word("et.appearance.scope")).control(choice( - &this, - "background_scope", - &SCOPES, - settings.background_scope, - |settings, scope| settings.background_scope = scope, - cx, - )), - ) - .row(SettingsRow::new(word("et.appearance.blur")).control(switch( + .row(SettingsRow::new(word("et.appearance.opacity")).control(opacity)); + for row in self.picture.rows(&this, settings, cx) { + background = background.row(row); + } + let mut background = + background.row(SettingsRow::new(word("et.appearance.blur")).control(switch( &this, "background_blur", settings.background_blur, diff --git a/src/settings/window/picture.rs b/src/settings/window/picture.rs new file mode 100644 index 00000000..788c5c85 --- /dev/null +++ b/src/settings/window/picture.rs @@ -0,0 +1,219 @@ +//! The picture's rows: its file, scope, size, position and repeat. + +use std::str::FromStr; + +use ely_gpui_component::{ + feedback::InlineMessage, + forms::{Input, InputEvent, PathInput, TextInput}, + i18n::text, + primitives::Severity, + settings::SettingsRow, + theme::ActiveTheme, +}; +use gpui::{ + App, Context, Div, Entity, IntoElement, ParentElement, SharedString, Styled, Subscription, + Window, div, prelude::*, +}; + +use super::{ + SettingsWindow, choice, + terminal::{chosen, path_field}, +}; +use crate::settings::{BackgroundScope, PictureRepeat, Settings, SettingsStore}; + +/// Each picture scope, its key and its name's key. +const SCOPES: [(BackgroundScope, &str, &str); 2] = [ + ( + BackgroundScope::Terminal, + "terminal", + "et.appearance.scope_terminal", + ), + ( + BackgroundScope::Window, + "window", + "et.appearance.scope_window", + ), +]; + +/// Each repeat, its key and its name's key. +const REPEATS: [(PictureRepeat, &str, &str); 4] = [ + ( + PictureRepeat::NoRepeat, + "no_repeat", + "et.appearance.picture_repeat_no_repeat", + ), + ( + PictureRepeat::Repeat, + "repeat", + "et.appearance.picture_repeat_repeat", + ), + ( + PictureRepeat::RepeatX, + "repeat_x", + "et.appearance.picture_repeat_x", + ), + ( + PictureRepeat::RepeatY, + "repeat_y", + "et.appearance.picture_repeat_y", + ), +]; + +/// The picture's fields: its file, size and position, and why each is not saved. +pub(super) struct Fields { + pub(super) image: Entity, + size: Entity, + position: Entity, + pub(super) refused_image: Option, + refused_size: Option, + refused_position: Option, + _typed: Vec, +} + +impl Fields { + /// The fields as the settings hold them, each saved as it is typed. + pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self { + let settings = SettingsStore::get(cx).clone(); + let image = cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(settings.background_image.clone(), cx); + input + }); + let size = cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(settings.background_size.to_string(), cx); + input + }); + let position = cx.new(|cx| { + let mut input = TextInput::new(window, cx); + input.set_text(settings.background_position.to_string(), cx); + input + }); + let mut fields = Self { + image, + size, + position, + refused_image: None, + refused_size: None, + refused_position: None, + _typed: Vec::new(), + }; + fields.watch(cx); + fields + } + + /// Saves each field as the user types, or says why it is not saved. + fn watch(&mut self, cx: &mut Context) { + self._typed.push( + cx.subscribe(&self.image, |this, input, event: &InputEvent, cx| { + if let InputEvent::Changed = event { + let text = input.read(cx).text().to_string(); + let put = |settings: &mut Settings, text| settings.background_image = text; + this.picture.refused_image = this.save_typed("background_image", text, put, cx); + } + }), + ); + self._typed.push( + cx.subscribe(&self.size, |this, input, event: &InputEvent, cx| { + if let InputEvent::Changed = event { + let text = input.read(cx).text().to_string(); + let refused = save( + this, + "background_size", + text, + |settings, size| settings.background_size = size, + cx, + ); + this.picture.refused_size = refused; + } + }), + ); + self._typed.push( + cx.subscribe(&self.position, |this, input, event: &InputEvent, cx| { + if let InputEvent::Changed = event { + let text = input.read(cx).text().to_string(); + let refused = save( + this, + "background_position", + text, + |settings, position| settings.background_position = position, + cx, + ); + this.picture.refused_position = refused; + } + }), + ); + } + + /// The rows the picture fills: its file, scope, size, position and repeat. + pub(super) fn rows( + &self, + this: &Entity, + settings: &Settings, + cx: &App, + ) -> Vec { + let word = |key: &str| text(cx, key, &[]); + let image = PathInput::new("settings-background-image", &self.image) + .on_choose(chosen(this, "background_image")); + let image = path_field(image, self.refused_image.clone(), cx); + let size = field(Input::new(&self.size), self.refused_size.clone(), cx); + let position = field( + Input::new(&self.position), + self.refused_position.clone(), + cx, + ); + vec![ + SettingsRow::new(word("et.appearance.image")) + .description(word("et.appearance.image_hint")) + .control(image), + SettingsRow::new(word("et.appearance.scope")).control(choice( + this, + "background_scope", + &SCOPES, + settings.background_scope, + |settings, scope| settings.background_scope = scope, + cx, + )), + SettingsRow::new(word("et.appearance.picture_size")) + .description(word("et.appearance.picture_size_hint")) + .control(size), + SettingsRow::new(word("et.appearance.picture_position")) + .description(word("et.appearance.picture_position_hint")) + .control(position), + SettingsRow::new(word("et.appearance.picture_repeat")).control(choice( + this, + "background_repeat", + &REPEATS, + settings.background_repeat, + |settings, repeat| settings.background_repeat = repeat, + cx, + )), + ] + } +} + +/// A field the picture's text fills, with why it is not saved. +fn field(input: impl IntoElement, refused: Option, cx: &App) -> Div { + let refused = refused.map(|why| InlineMessage::new(Severity::Danger, why)); + div() + .w(cx.theme().label_width()) + .flex() + .flex_col() + .gap_1() + .child(input) + .children(refused) +} + +/// Saves `text` as `T` through `put`, or returns why it is not one. +fn save>( + this: &mut SettingsWindow, + field: &'static str, + text: String, + put: impl FnOnce(&mut Settings, T), + cx: &mut Context, +) -> Option { + match text.trim().parse::() { + Ok(value) => this.save_typed(field, text, move |settings, _| put(settings, value), cx), + Err(error) => Some(format!("{error:#}").into()), + } +} diff --git a/src/workspace/wallpaper.rs b/src/workspace/wallpaper.rs index f51740c5..a73c2aeb 100644 --- a/src/workspace/wallpaper.rs +++ b/src/workspace/wallpaper.rs @@ -8,10 +8,13 @@ use std::{ use gpui::{ AnyElement, App, Bounds, Hsla, ImageAssetLoader, IntoElement, ObjectFit, ParentElement, Pixels, - Resource, Size, Styled, StyledImage, Window, canvas, div, img, point, px, size, + RenderImage, Resource, Size, Styled, StyledImage, Window, canvas, div, img, point, px, size, }; -use crate::settings::{BackgroundScope, Settings}; +use crate::settings::{BackgroundScope, PictureRepeat, Settings}; + +/// The most tiles one picture draws; past it the picture draws once. +const MAX_TILES: usize = 400; thread_local! { /// The terminal body's bounds, as the last frame laid them. @@ -56,49 +59,125 @@ pub(super) fn paint( if settings.background_image.is_empty() { return None; } - let ground = div() - .absolute() - .inset_0() - .opacity(settings.background_opacity) - .child(div().absolute().inset_0().bg(base)); + let ground = || { + div() + .absolute() + .inset_0() + .opacity(settings.background_opacity) + .child(div().absolute().inset_0().bg(base)) + }; let Some(body) = TERMINAL.get() else { - return Some(ground.into_any_element()); + return Some(ground().into_any_element()); }; let bounds = box_of(settings.background_scope, body, window.viewport_size()); let path: Arc = Path::new(&settings.background_image).into(); let source = Resource::Path(path.clone()); let Some(loaded) = window.use_asset::(&source, cx) else { - return Some(ground.into_any_element()); + return Some(ground().into_any_element()); }; - match loaded { - Ok(image) => Some( - ground - .child( - img(image) - .absolute() - .left(bounds.origin.x) - .top(bounds.origin.y) - .w(bounds.size.width) - .h(bounds.size.height) - .object_fit(ObjectFit::Cover), - ) - .into_any_element(), - ), + let image = match loaded { + Ok(image) => image, Err(error) => { - let fresh = FAILED.with(|failed| { - let mut failed = failed.borrow_mut(); - if failed.as_deref() == Some(&*path) { - return false; - } - *failed = Some(path.to_path_buf()); - true - }); - if fresh { - log::error!("background: {}: {error}", path.display()); - } - Some(ground.into_any_element()) + log_failure(&path, error); + return Some(ground().into_any_element()); + } + }; + let scale = window.scale_factor(); + let pixels = image.size(0); + let intrinsic = ( + pixels.width.0 as f32 / scale, + pixels.height.0 as f32 / scale, + ); + if intrinsic.0 <= 0.0 || intrinsic.1 <= 0.0 { + return Some(ground().into_any_element()); + } + let container = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let drawn = settings.background_size.points(intrinsic, container); + let free = (container.0 - drawn.0, container.1 - drawn.1); + let place = settings.background_position.points(free); + let mut boxed = div() + .absolute() + .left(bounds.origin.x) + .top(bounds.origin.y) + .w(bounds.size.width) + .h(bounds.size.height); + for (x, y) in tiles(settings.background_repeat, place, drawn, container) { + boxed = boxed.child(tile(&image, x, y, drawn)); + } + Some(ground().child(boxed).into_any_element()) +} + +/// One tile of the picture, at `x` and `y` in its box. +fn tile(image: &Arc, x: f32, y: f32, drawn: (f32, f32)) -> impl IntoElement { + img(image.clone()) + .absolute() + .left(px(x)) + .top(px(y)) + .w(px(drawn.0)) + .h(px(drawn.1)) + .object_fit(ObjectFit::Fill) +} + +/// Where each tile starts, from the picture's place in its box; past `MAX_TILES`, the first one alone. +fn tiles( + repeat: PictureRepeat, + place: (f32, f32), + drawn: (f32, f32), + container: (f32, f32), +) -> Vec<(f32, f32)> { + let xs = if matches!(repeat, PictureRepeat::Repeat | PictureRepeat::RepeatX) { + along(place.0, drawn.0, container.0) + } else { + vec![place.0] + }; + let ys = if matches!(repeat, PictureRepeat::Repeat | PictureRepeat::RepeatY) { + along(place.1, drawn.1, container.1) + } else { + vec![place.1] + }; + if xs.len() * ys.len() > MAX_TILES { + return vec![place]; + } + let mut tiles = Vec::with_capacity(xs.len() * ys.len()); + for y in ys { + for x in &xs { + tiles.push((*x, y)); } } + tiles +} + +/// The starts of one repeated tile across `extent`, from `at`. +fn along(at: f32, step: f32, extent: f32) -> Vec { + if step <= 0.0 { + return vec![at]; + } + let mut first = at.rem_euclid(step); + if first > 0.0 { + first -= step; + } + let mut starts = Vec::new(); + let mut start = first; + while start < extent && starts.len() < MAX_TILES { + starts.push(start); + start += step; + } + starts +} + +/// Logs a picture that would not load, once per file. +fn log_failure(path: &Arc, error: impl std::fmt::Display) { + let fresh = FAILED.with(|failed| { + let mut failed = failed.borrow_mut(); + if failed.as_deref() == Some(&**path) { + return false; + } + *failed = Some(path.to_path_buf()); + true + }); + if fresh { + log::error!("background: {}: {error}", path.display()); + } } #[cfg(test)] @@ -125,4 +204,50 @@ mod tests { assert_eq!(bounds.origin, point(px(0.0), px(38.0))); assert_eq!(bounds.size, size(px(1200.0), px(662.0))); } + + #[test] + fn a_repeated_picture_tiles_across_and_down() { + let tiled = tiles( + PictureRepeat::Repeat, + (0.0, 0.0), + (100.0, 100.0), + (250.0, 250.0), + ); + assert_eq!(tiled.len(), 9); + assert!(tiled.contains(&(0.0, 0.0))); + assert!(tiled.contains(&(200.0, 200.0))); + } + + #[test] + fn a_picture_out_of_place_tiles_from_before_the_box() { + let tiled = tiles( + PictureRepeat::RepeatX, + (-30.0, 10.0), + (100.0, 50.0), + (250.0, 250.0), + ); + assert_eq!(tiled, vec![(-30.0, 10.0), (70.0, 10.0), (170.0, 10.0)]); + } + + #[test] + fn one_picture_stays_where_it_was_placed() { + let placed = tiles( + PictureRepeat::NoRepeat, + (12.0, 20.0), + (100.0, 100.0), + (250.0, 250.0), + ); + assert_eq!(placed, vec![(12.0, 20.0)]); + } + + #[test] + fn a_picture_too_small_to_tile_draws_once() { + let once = tiles( + PictureRepeat::Repeat, + (0.0, 0.0), + (1.0, 1.0), + (1000.0, 1000.0), + ); + assert_eq!(once.len(), 1); + } } diff --git a/tasks/acceptance.md b/tasks/acceptance.md index f3d3e8e3..b0c90d3f 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -13,7 +13,7 @@ What no check reached: issue #29's physical keyboard and fcitx5's own forwarding | Silent: no sound, bell, notification but a session's or a newer release's, telemetry, tip or onboarding | T10, T12, T40, T56 | a bell is logged and marks a hidden row; the one network call is the update check the owner asked for in T56, `curl` to GitHub's `releases/latest` at start and every 24 hours a run lasts while Check for updates is on, and when the switch turns on or Check now is pressed, neither of which posts; the notifications are a session's and, at each automatic check that finds one, a newer release's, under its release's tag, through gpui's, which sets no sound on macOS and a silent toast on Windows; on Linux the notification daemon decides | Pass | | Clean: no subtitle, tagline, logging option, status bar or badge without state | T11–T18 | reviews of the layout and settings tasks | Pass | | Built-in themes, each with a preview, Catppuccin among them; a theme colors terminal and chrome | T03, T17 | `themes::tests`; T17 review | Pass | -| The user's own themes from Ely Dark: New theme, their name and 21 colors, Delete; built-ins never change; a background image under the terminal or the window, as Picture scope says | T109 | `themes::custom` tests: Ely Dark's copy under the next id, each slot's color, a color, a prefix or a name that does not read names the theme; `settings` tests: a custom id chosen, one gone or held twice refused, a background image a file at a full path, its scope the terminal unless the window is chosen; on macOS `shot`: New theme made and wore `custom-1`, the picker saved a foreground and the chrome wore it, the name saved at each key, Delete wore Ely Dark; a PNG drew under the text, a script named as one logged one error | Pass | +| The user's own themes from Ely Dark: New theme, their name and 21 colors, Delete; built-ins never change; a background image under the terminal or the window, as Picture scope says, sized, placed and repeated as CSS's size, position and repeat say | T109 | `themes::custom` tests: Ely Dark's copy under the next id, each slot's color, a color, a prefix or a name that does not read names the theme; `settings` tests: a custom id chosen, one gone or held twice refused, a background image a file at a full path, its scope the terminal unless the window is chosen, a `51% auto` size, a `125% 100%` place and a repeat read back; `workspace::wallpaper` tests: the terminal's box, the window's box below the title bar, a picture tiled across and down, one placed before its box, one drawn once; on macOS `shot`: New theme made and wore `custom-1`, the picker saved a foreground and the chrome wore it, the name saved at each key, Delete wore Ely Dark; a PNG drew under the text, a script named as one logged one error | Pass | | The background blurs on macOS, Windows, Wayland and X11 | T19–T21 | see §11 | Partial | ## 2. Foundation