├── .gitignore ├── tests ├── .gitignore ├── parts │ ├── part-left.expected.png │ ├── part-top.expected.png │ ├── part-bottom.expected.png │ ├── part-header.expected.png │ ├── part-right.expected.png │ ├── combined-parts.expected.png │ ├── combined-parts-scale-2.expected.png │ ├── combined-parts-no-border.expected.png │ ├── combined-parts-no-titlebar.expected.png │ └── combined-parts-no-titlebar-and-border.expected.png ├── shadow │ ├── side-left.expected.png │ ├── side-left-flipped.expected.png │ ├── side-left-rotated.expected.png │ ├── corner-source-active.expected.png │ ├── corner-source-inactive.expected.png │ ├── side-single-row-active.expected.png │ ├── side-left-flipped-rotated.expected.png │ └── side-single-row-inactive.expected.png └── subsurface-layout │ ├── layout.expected.png │ ├── layout-no-border.expected.png │ ├── layout-no-titlebar.expected.png │ └── layout-no-titlebar-and-border.expected.png ├── src ├── title │ ├── Cantarell-Regular.ttf │ ├── dumb.rs │ ├── config.rs │ ├── font_preference.rs │ ├── crossfont_renderer.rs │ └── ab_glyph_renderer.rs ├── wl_typed.rs ├── title.rs ├── config.rs ├── theme.rs ├── pointer.rs ├── buttons.rs ├── parts.rs ├── shadow.rs └── lib.rs ├── .github └── workflows │ ├── changelog.yml │ └── rust.yml ├── README.md ├── LICENSE ├── Cargo.toml ├── CHANGELOG.md └── examples └── window.rs /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | *.got.png 2 | -------------------------------------------------------------------------------- /src/title/Cantarell-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/src/title/Cantarell-Regular.ttf -------------------------------------------------------------------------------- /tests/parts/part-left.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/part-left.expected.png -------------------------------------------------------------------------------- /tests/parts/part-top.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/part-top.expected.png -------------------------------------------------------------------------------- /tests/parts/part-bottom.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/part-bottom.expected.png -------------------------------------------------------------------------------- /tests/parts/part-header.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/part-header.expected.png -------------------------------------------------------------------------------- /tests/parts/part-right.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/part-right.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-left.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-left.expected.png -------------------------------------------------------------------------------- /tests/parts/combined-parts.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/combined-parts.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-left-flipped.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-left-flipped.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-left-rotated.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-left-rotated.expected.png -------------------------------------------------------------------------------- /tests/subsurface-layout/layout.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/subsurface-layout/layout.expected.png -------------------------------------------------------------------------------- /tests/parts/combined-parts-scale-2.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/combined-parts-scale-2.expected.png -------------------------------------------------------------------------------- /tests/shadow/corner-source-active.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/corner-source-active.expected.png -------------------------------------------------------------------------------- /tests/parts/combined-parts-no-border.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/combined-parts-no-border.expected.png -------------------------------------------------------------------------------- /tests/shadow/corner-source-inactive.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/corner-source-inactive.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-single-row-active.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-single-row-active.expected.png -------------------------------------------------------------------------------- /tests/parts/combined-parts-no-titlebar.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/combined-parts-no-titlebar.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-left-flipped-rotated.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-left-flipped-rotated.expected.png -------------------------------------------------------------------------------- /tests/shadow/side-single-row-inactive.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/shadow/side-single-row-inactive.expected.png -------------------------------------------------------------------------------- /tests/subsurface-layout/layout-no-border.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/subsurface-layout/layout-no-border.expected.png -------------------------------------------------------------------------------- /tests/subsurface-layout/layout-no-titlebar.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/subsurface-layout/layout-no-titlebar.expected.png -------------------------------------------------------------------------------- /tests/parts/combined-parts-no-titlebar-and-border.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/parts/combined-parts-no-titlebar-and-border.expected.png -------------------------------------------------------------------------------- /tests/subsurface-layout/layout-no-titlebar-and-border.expected.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PolyMeilex/sctk-adwaita/HEAD/tests/subsurface-layout/layout-no-titlebar-and-border.expected.png -------------------------------------------------------------------------------- /src/title/dumb.rs: -------------------------------------------------------------------------------- 1 | use tiny_skia::{Color, Pixmap}; 2 | 3 | #[derive(Debug)] 4 | pub struct DumbTitleText {} 5 | 6 | impl DumbTitleText { 7 | pub fn update_scale(&mut self, _scale: u32) {} 8 | 9 | pub fn update_title>(&mut self, _title: S) {} 10 | 11 | pub fn update_color(&mut self, _color: Color) {} 12 | 13 | pub fn pixmap(&self) -> Option<&Pixmap> { 14 | None 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/workflows/changelog.yml: -------------------------------------------------------------------------------- 1 | name: Changelog check 2 | 3 | on: 4 | pull_request: 5 | branches: [ master ] 6 | types: [ opened, synchronize, reopened, labeled, unlabeled ] 7 | 8 | jobs: 9 | Changelog-Entry-Check: 10 | name: Check Changelog Action 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: tarides/changelog-check-action@v2 14 | with: 15 | changelog: CHANGELOG.md 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Adwaita-like SCTK Frame 2 | 3 | | | | 4 | |---|---| 5 | |![active](https://i.imgur.com/WdO8e0i.png)|![hover](https://i.imgur.com/TkUq2WF.png)| 6 | ![inactive](https://i.imgur.com/MTFdSjK.png)| 7 | 8 | ### Dark mode: 9 | ![image](https://user-images.githubusercontent.com/20758186/169424673-3b9fa022-f112-4928-8360-305a714ba979.png) 10 | 11 | ## Title text: ab_glyph 12 | By default title text is drawn with _ab_glyph_ crate. This can be disabled by disabling default features. 13 | 14 | ## Title text: crossfont 15 | Alternatively title text may be drawn with _crossfont_ crate. This adds a requirement on _freetype_. 16 | 17 | ```toml 18 | sctk-adwaita = { default-features = false, features = ["crossfont"] } 19 | ``` 20 | -------------------------------------------------------------------------------- /src/title/config.rs: -------------------------------------------------------------------------------- 1 | //! System font configuration. 2 | use crate::title::font_preference::FontPreference; 3 | use std::process::Command; 4 | 5 | /// Query system for which font to use for window titles. 6 | pub(crate) fn titlebar_font() -> Option { 7 | // outputs something like: `'Cantarell Bold 12'` 8 | let stdout = Command::new("gsettings") 9 | .args(["get", "org.gnome.desktop.wm.preferences", "titlebar-font"]) 10 | .output() 11 | .ok() 12 | .and_then(|out| String::from_utf8(out.stdout).ok())?; 13 | 14 | FontPreference::from_name_style_size( 15 | stdout 16 | .trim() 17 | .trim_end_matches('\'') 18 | .trim_start_matches('\''), 19 | ) 20 | } 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Bartłomiej Maryńczak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "sctk-adwaita" 3 | version = "0.11.0" 4 | edition = "2021" 5 | authors = ["Poly "] 6 | keywords = ["sctk"] 7 | license = "MIT" 8 | repository = "https://github.com/PolyMeilex/sctk-adwaita" 9 | documentation = "https://docs.rs/sctk-adwaita" 10 | description = "Adwaita-like SCTK Frame" 11 | 12 | exclude = [ 13 | "tests/*", 14 | ] 15 | 16 | [dependencies] 17 | log = "0.4" 18 | memmap2 = { version = "0.9.0", optional = true } 19 | tiny-skia = { version = "0.11", default-features = false, features = [ 20 | "std", 21 | "simd", 22 | ] } 23 | smithay-client-toolkit = { version = "0.20.0", default-features = false } 24 | 25 | # Draw title text using crossfont `--features crossfont` 26 | crossfont = { version = "0.9.0", optional = true } 27 | # Draw title text using ab_glyph `--features ab_glyph` 28 | ab_glyph = { version = "0.2.17", optional = true } 29 | 30 | [dev-dependencies] 31 | tiny-skia = { version = "0.11", default-features = false, features = [ 32 | "std", 33 | "png-format", 34 | "simd", 35 | ] } 36 | 37 | [features] 38 | default = ["ab_glyph"] 39 | crossfont = ["dep:crossfont"] 40 | ab_glyph = ["dep:ab_glyph", "memmap2"] 41 | -------------------------------------------------------------------------------- /src/wl_typed.rs: -------------------------------------------------------------------------------- 1 | use std::{marker::PhantomData, ops::Deref}; 2 | 3 | use smithay_client_toolkit::reexports::client::{Dispatch, Proxy}; 4 | 5 | #[derive(Debug)] 6 | pub struct WlTyped(I, PhantomData); 7 | 8 | impl WlTyped 9 | where 10 | I: Proxy, 11 | DATA: Send + Sync + 'static, 12 | { 13 | #[allow(clippy::extra_unused_type_parameters)] 14 | pub fn wrap(i: I) -> Self 15 | where 16 | STATE: Dispatch, 17 | { 18 | Self(i, PhantomData) 19 | } 20 | 21 | pub fn inner(&self) -> &I { 22 | &self.0 23 | } 24 | 25 | #[allow(dead_code)] 26 | pub fn data(&self) -> &DATA { 27 | // Generic on Self::wrap makes sure that this will never panic 28 | #[allow(clippy::unwrap_used)] 29 | self.0.data().unwrap() 30 | } 31 | } 32 | 33 | impl Clone for WlTyped { 34 | fn clone(&self) -> Self { 35 | Self(self.0.clone(), PhantomData) 36 | } 37 | } 38 | 39 | impl Deref for WlTyped { 40 | type Target = I; 41 | 42 | fn deref(&self) -> &Self::Target { 43 | &self.0 44 | } 45 | } 46 | 47 | impl PartialEq for WlTyped { 48 | fn eq(&self, other: &Self) -> bool { 49 | self.0 == other.0 50 | } 51 | } 52 | impl Eq for WlTyped {} 53 | -------------------------------------------------------------------------------- /src/title.rs: -------------------------------------------------------------------------------- 1 | use tiny_skia::{Color, Pixmap}; 2 | 3 | #[cfg(any(feature = "crossfont", feature = "ab_glyph"))] 4 | mod config; 5 | #[cfg(any(feature = "crossfont", feature = "ab_glyph"))] 6 | mod font_preference; 7 | 8 | #[cfg(feature = "crossfont")] 9 | mod crossfont_renderer; 10 | 11 | #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] 12 | mod ab_glyph_renderer; 13 | 14 | #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] 15 | mod dumb; 16 | 17 | #[derive(Debug)] 18 | pub struct TitleText { 19 | #[cfg(feature = "crossfont")] 20 | imp: crossfont_renderer::CrossfontTitleText, 21 | #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] 22 | imp: ab_glyph_renderer::AbGlyphTitleText, 23 | #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] 24 | imp: dumb::DumbTitleText, 25 | } 26 | 27 | impl TitleText { 28 | pub fn new(color: Color) -> Option { 29 | #[cfg(feature = "crossfont")] 30 | return crossfont_renderer::CrossfontTitleText::new(color) 31 | .ok() 32 | .map(|imp| Self { imp }); 33 | 34 | #[cfg(all(not(feature = "crossfont"), feature = "ab_glyph"))] 35 | return Some(Self { 36 | imp: ab_glyph_renderer::AbGlyphTitleText::new(color), 37 | }); 38 | 39 | #[cfg(all(not(feature = "crossfont"), not(feature = "ab_glyph")))] 40 | { 41 | let _ = color; 42 | return None; 43 | } 44 | } 45 | 46 | pub fn update_scale(&mut self, scale: u32) { 47 | self.imp.update_scale(scale) 48 | } 49 | 50 | pub fn update_title(&mut self, title: impl Into) { 51 | self.imp.update_title(title) 52 | } 53 | 54 | pub fn update_color(&mut self, color: Color) { 55 | self.imp.update_color(color) 56 | } 57 | 58 | pub fn pixmap(&self) -> Option<&Pixmap> { 59 | self.imp.pixmap() 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/config.rs: -------------------------------------------------------------------------------- 1 | //! System configuration. 2 | use std::process::Command; 3 | 4 | /// Query system to see if dark theming should be preferred. 5 | pub(crate) fn prefer_dark() -> bool { 6 | // outputs something like: `variant variant uint32 1` 7 | let stdout = Command::new("dbus-send") 8 | .arg("--reply-timeout=100") 9 | .arg("--print-reply=literal") 10 | .arg("--dest=org.freedesktop.portal.Desktop") 11 | .arg("/org/freedesktop/portal/desktop") 12 | .arg("org.freedesktop.portal.Settings.Read") 13 | .arg("string:org.freedesktop.appearance") 14 | .arg("string:color-scheme") 15 | .output() 16 | .ok() 17 | .and_then(|out| String::from_utf8(out.stdout).ok()); 18 | 19 | if matches!(stdout, Some(ref s) if s.is_empty()) { 20 | log::error!("XDG Settings Portal did not return response in time: timeout: 100ms, key: color-scheme"); 21 | } 22 | 23 | matches!(stdout, Some(s) if s.trim().ends_with("uint32 1")) 24 | } 25 | 26 | /// Query system configuration for buttons layout. 27 | /// Should be updated to use standard xdg-desktop-portal specs once available 28 | /// https://github.com/flatpak/xdg-desktop-portal/pull/996 29 | pub(crate) fn get_button_layout_config() -> Option<(String, String)> { 30 | let config_string = Command::new("dbus-send") 31 | .arg("--reply-timeout=100") 32 | .arg("--print-reply=literal") 33 | .arg("--dest=org.freedesktop.portal.Desktop") 34 | .arg("/org/freedesktop/portal/desktop") 35 | .arg("org.freedesktop.portal.Settings.Read") 36 | .arg("string:org.gnome.desktop.wm.preferences") 37 | .arg("string:button-layout") 38 | .output() 39 | .ok() 40 | .and_then(|out| String::from_utf8(out.stdout).ok())?; 41 | 42 | let sides_split: Vec<_> = config_string 43 | // Taking last word 44 | .rsplit(' ') 45 | .next()? 46 | // Split by left/right side 47 | .split(':') 48 | // Only two sides 49 | .take(2) 50 | .collect(); 51 | 52 | match sides_split.as_slice() { 53 | [left, right] => Some((left.to_string(), right.to_string())), 54 | _ => None, 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [Unreleased] 2 | - Add the option to hide the borders `FrameConfig::hide_border` 3 | 4 | ## 0.11.0 5 | - Improve `ab_glyph` rendering to properly account for glyph outlines that would have previously been out of bounds 6 | - Align with new adwaita blue tint #71 7 | - Add ability to hide the titlebar #69 8 | - Bump sctk to 0.20 9 | - Bump crossfont to 0.9.0 10 | 11 | ## 0.10.1 12 | - Panic hardening of ab-glyph (#64) 13 | 14 | ## 0.10.0 15 | - Bump sctk to 0.19 16 | 17 | ## 0.9.0 18 | - Bump crossfont to 0.8.0 (#60) 19 | 20 | ## 0.8.1 21 | - Fix `ab_glyph` renderer panicking with integer scale factor 3 (#50) 22 | - Improved roundness of headerbar (#51) 23 | 24 | ## 0.8.0 25 | - **Breaking:** `AdwaitaFrame::new` now takes `Arc` as an argument 26 | - Fix leftmost title pixel sometimes being cut off (#45) 27 | - Fix transparency in ab_glyph renderer (#44) 28 | - Extended resize corners (#47) 29 | - Center maximize icon (#46) 30 | - Window shadows (#43) 31 | 32 | ### Dependencies updates 33 | - Bump crossfont to 0.6.0 (#52) 34 | 35 | ## 0.7.0 36 | - **Breaking:** `wayland-csd-frame` is now used as a part of the public interface. 37 | 38 | ## 0.6.1 39 | - Bump tiny-skia to v0.11 (#32) 40 | - cleanup: Remove debug println (#29) 41 | - Support custom header buttons layouts (#30) 42 | - The double click threshold value was raised to `400ms` 43 | 44 | ## 0.6.0 45 | - Update the `smithay-client-toolkit` to `0.17.0` 46 | - Don't use tiny-skia's default features 47 | 48 | ## 0.5.4 49 | - Timeout dbus call to settings portal (100ms) 50 | 51 | ## 0.5.3 52 | - `ab_glyph` titles will read the system title font using memory mapped buffers instead of reading to heap. 53 | Lowers RAM usage. 54 | - Improve titlebar-font config parsing to correctly handle more font names. 55 | 56 | ## 0.5.2 57 | - `ab_glyph` & `crossfont` titles will use gnome "titlebar-font" config if available. 58 | - `ab_glyph` titles are now more consistent with `crossfont` titles both using system sans 59 | if no better font config is available. 60 | - Rounded corners are now disabled on maximized and tiled windows. 61 | - Double click interval is now 400ms (as previous 1s interval was caused by bug fixed in 0.5.1) 62 | 63 | ## 0.5.1 64 | - Use dbus org.freedesktop.portal.Settings to automatically choose light or dark theming. 65 | - Double click detection fix. 66 | - Apply button click on release instead of press. 67 | 68 | ## 0.5.0 69 | - `title` feature got removed 70 | - `ab_glyph` default feature got added (for `ab_glyph` based title rendering) 71 | - `crossfont` feature got added (for `crossfont` based title rendering) 72 | - Can be enable like this: 73 | ```toml 74 | sctk-adwaita = { default-features = false, features = ["crossfont"] } 75 | ``` 76 | -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: Continuous integration 4 | 5 | jobs: 6 | fmt: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v2 10 | - uses: actions-rs/toolchain@v1 11 | with: 12 | profile: minimal 13 | toolchain: stable 14 | override: true 15 | components: rustfmt 16 | 17 | - uses: actions-rs/cargo@v1 18 | with: 19 | command: fmt 20 | args: --all -- --check 21 | 22 | clippy: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - uses: actions/checkout@v2 26 | - uses: actions-rs/toolchain@v1 27 | with: 28 | profile: minimal 29 | toolchain: stable 30 | override: true 31 | components: clippy 32 | 33 | - name: Run cargo clippy 34 | uses: actions-rs/clippy-check@v1 35 | with: 36 | token: ${{ secrets.GITHUB_TOKEN }} 37 | args: -- -D clippy::print_stderr -D clippy::print_stdout -D clippy::dbg_macro -D clippy::exit -D clippy::unwrap_used -D clippy::expect_used -D clippy::panic 38 | 39 | default-build: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v2 43 | - uses: actions-rs/toolchain@v1 44 | with: 45 | profile: minimal 46 | toolchain: stable 47 | override: true 48 | 49 | - uses: actions-rs/cargo@v1 50 | with: 51 | command: build 52 | 53 | no-default-build: 54 | runs-on: ubuntu-latest 55 | steps: 56 | - uses: actions/checkout@v2 57 | - uses: actions-rs/toolchain@v1 58 | with: 59 | profile: minimal 60 | toolchain: stable 61 | override: true 62 | 63 | - uses: actions-rs/cargo@v1 64 | with: 65 | command: build 66 | args: --no-default-features 67 | 68 | crossfont-build: 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v2 72 | 73 | - name: System dependencies 74 | run: sudo apt-get update && sudo apt-get install pkg-config cmake libfreetype6-dev libfontconfig1-dev 75 | 76 | - uses: actions-rs/toolchain@v1 77 | with: 78 | profile: minimal 79 | toolchain: stable 80 | override: true 81 | 82 | - uses: actions-rs/cargo@v1 83 | with: 84 | command: build 85 | args: --no-default-features --features crossfont 86 | 87 | ab-glyph-build: 88 | runs-on: ubuntu-latest 89 | steps: 90 | - uses: actions/checkout@v2 91 | 92 | - uses: actions-rs/toolchain@v1 93 | with: 94 | profile: minimal 95 | toolchain: stable 96 | override: true 97 | 98 | - uses: actions-rs/cargo@v1 99 | with: 100 | command: build 101 | args: --no-default-features --features ab_glyph 102 | -------------------------------------------------------------------------------- /src/title/font_preference.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug)] 2 | pub(crate) struct FontPreference { 3 | pub name: String, 4 | pub style: Option, 5 | pub pt_size: f32, 6 | } 7 | 8 | impl Default for FontPreference { 9 | fn default() -> Self { 10 | Self { 11 | name: "sans-serif".into(), 12 | style: None, 13 | pt_size: 10.0, 14 | } 15 | } 16 | } 17 | 18 | impl FontPreference { 19 | /// Parse config string like `Cantarell 12`, `Cantarell Bold 11`, `Noto Serif CJK HK Bold 12`. 20 | pub fn from_name_style_size(conf: &str) -> Option { 21 | // assume last is size, 2nd last is style and the rest is name. 22 | match conf.rsplit_once(' ') { 23 | Some((head, tail)) if tail.chars().all(|c| c.is_numeric()) => { 24 | let pt_size: f32 = tail.parse().unwrap_or(10.0); 25 | match head.rsplit_once(' ') { 26 | Some((name, style)) if !name.is_empty() => Some(Self { 27 | name: name.into(), 28 | style: Some(style.into()), 29 | pt_size, 30 | }), 31 | None if !head.is_empty() => Some(Self { 32 | name: head.into(), 33 | style: None, 34 | pt_size, 35 | }), 36 | _ => None, 37 | } 38 | } 39 | Some((head, tail)) if !head.is_empty() => Some(Self { 40 | name: head.into(), 41 | style: Some(tail.into()), 42 | pt_size: 10.0, 43 | }), 44 | None if !conf.is_empty() => Some(Self { 45 | name: conf.into(), 46 | style: None, 47 | pt_size: 10.0, 48 | }), 49 | _ => None, 50 | } 51 | } 52 | } 53 | 54 | #[cfg(test)] 55 | mod tests { 56 | #![allow(clippy::unwrap_used)] 57 | 58 | use super::*; 59 | 60 | #[test] 61 | fn pref_from_multi_name_variant_size() { 62 | let pref = FontPreference::from_name_style_size("Noto Serif CJK HK Bold 12").unwrap(); 63 | assert_eq!(pref.name, "Noto Serif CJK HK"); 64 | assert_eq!(pref.style, Some("Bold".into())); 65 | assert!((pref.pt_size - 12.0).abs() < f32::EPSILON); 66 | } 67 | 68 | #[test] 69 | fn pref_from_name_variant_size() { 70 | let pref = FontPreference::from_name_style_size("Cantarell Bold 12").unwrap(); 71 | assert_eq!(pref.name, "Cantarell"); 72 | assert_eq!(pref.style, Some("Bold".into())); 73 | assert!((pref.pt_size - 12.0).abs() < f32::EPSILON); 74 | } 75 | 76 | #[test] 77 | fn pref_from_name_size() { 78 | let pref = FontPreference::from_name_style_size("Cantarell 12").unwrap(); 79 | assert_eq!(pref.name, "Cantarell"); 80 | assert_eq!(pref.style, None); 81 | assert!((pref.pt_size - 12.0).abs() < f32::EPSILON); 82 | } 83 | 84 | #[test] 85 | fn pref_from_name() { 86 | let pref = FontPreference::from_name_style_size("Cantarell").unwrap(); 87 | assert_eq!(pref.name, "Cantarell"); 88 | assert_eq!(pref.style, None); 89 | assert!((pref.pt_size - 10.0).abs() < f32::EPSILON); 90 | } 91 | #[test] 92 | fn pref_from_multi_name_style() { 93 | let pref = FontPreference::from_name_style_size("Foo Bar Baz Bold").unwrap(); 94 | assert_eq!(pref.name, "Foo Bar Baz"); 95 | assert_eq!(pref.style, Some("Bold".into())); 96 | assert!((pref.pt_size - 10.0).abs() < f32::EPSILON); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/theme.rs: -------------------------------------------------------------------------------- 1 | pub use tiny_skia::Color; 2 | use tiny_skia::{Paint, Shader}; 3 | 4 | // https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L165 5 | pub(crate) const RESIZE_HANDLE_SIZE: u32 = 12; 6 | // https://gitlab.gnome.org/GNOME/gtk/-/blob/1bf88f1d81043fd99740e2f91e56ade7ede7303b/gtk/gtkwindow.c#L166 7 | pub(crate) const RESIZE_HANDLE_CORNER_SIZE: u32 = 24; 8 | pub(crate) const HEADER_SIZE: u32 = 35; 9 | pub(crate) const CORNER_RADIUS: u32 = 10; 10 | 11 | pub(crate) fn header_height(hide_header: bool) -> u32 { 12 | if hide_header { 13 | 0 14 | } else { 15 | HEADER_SIZE 16 | } 17 | } 18 | 19 | /// Edge is a border + shadows 20 | pub(crate) fn edge_size(hide_border: bool) -> u32 { 21 | crate::shadow::SHADOW_SIZE + border_size(hide_border) 22 | } 23 | 24 | /// Just border without shadows 25 | pub(crate) fn border_size(hide_border: bool) -> u32 { 26 | if hide_border { 27 | 0 28 | } else { 29 | 1 30 | } 31 | } 32 | 33 | /// The color theme to use with the decorations frame. 34 | #[derive(Debug, Clone)] 35 | pub struct ColorTheme { 36 | pub active: ColorMap, 37 | pub inactive: ColorMap, 38 | } 39 | 40 | impl ColorTheme { 41 | /// Automatically choose between light & dark themes based on: 42 | /// * dbus org.freedesktop.portal.Settings 43 | /// 44 | pub fn auto() -> Self { 45 | match crate::config::prefer_dark() { 46 | true => Self::dark(), 47 | false => Self::light(), 48 | } 49 | } 50 | 51 | /// Predefined light variant, which aims to replecate Adwaita theme. 52 | pub fn light() -> Self { 53 | Self { 54 | active: ColorMap { 55 | headerbar: Color::from_rgba8(235, 235, 235, 255), 56 | button_idle: Color::from_rgba8(216, 216, 216, 255), 57 | button_hover: Color::from_rgba8(207, 207, 207, 255), 58 | button_icon: Color::from_rgba8(42, 42, 42, 255), 59 | border_color: Color::from_rgba8(220, 220, 220, 255), 60 | font_color: Color::from_rgba8(47, 47, 47, 255), 61 | }, 62 | inactive: ColorMap { 63 | headerbar: Color::from_rgba8(250, 250, 250, 255), 64 | button_idle: Color::from_rgba8(240, 240, 240, 255), 65 | button_hover: Color::from_rgba8(216, 216, 216, 255), 66 | button_icon: Color::from_rgba8(148, 148, 148, 255), 67 | border_color: Color::from_rgba8(220, 220, 220, 255), 68 | font_color: Color::from_rgba8(150, 150, 150, 255), 69 | }, 70 | } 71 | } 72 | 73 | /// Predefined dark variant, which aims to replecate Adwaita-dark theme. 74 | pub fn dark() -> Self { 75 | Self { 76 | active: ColorMap { 77 | headerbar: Color::from_rgba8(50, 46, 46, 255), 78 | button_idle: Color::from_rgba8(71, 67, 67, 255), 79 | button_hover: Color::from_rgba8(79, 79, 79, 255), 80 | button_icon: Color::from_rgba8(255, 255, 255, 255), 81 | border_color: Color::from_rgba8(58, 58, 58, 255), 82 | font_color: Color::from_rgba8(255, 255, 255, 255), 83 | }, 84 | inactive: ColorMap { 85 | headerbar: Color::from_rgba8(38, 34, 34, 255), 86 | button_idle: Color::from_rgba8(49, 45, 45, 255), 87 | button_hover: Color::from_rgba8(57, 57, 57, 255), 88 | button_icon: Color::from_rgba8(144, 144, 144, 255), 89 | border_color: Color::from_rgba8(58, 58, 58, 255), 90 | font_color: Color::from_rgba8(144, 144, 144, 255), 91 | }, 92 | } 93 | } 94 | 95 | pub(crate) fn for_state(&self, active: bool) -> &ColorMap { 96 | if active { 97 | &self.active 98 | } else { 99 | &self.inactive 100 | } 101 | } 102 | } 103 | 104 | impl Default for ColorTheme { 105 | fn default() -> Self { 106 | Self::auto() 107 | } 108 | } 109 | 110 | /// The color map for various decorcation parts. 111 | #[derive(Debug, Clone)] 112 | pub struct ColorMap { 113 | pub headerbar: Color, 114 | pub button_idle: Color, 115 | pub button_hover: Color, 116 | pub button_icon: Color, 117 | pub border_color: Color, 118 | pub font_color: Color, 119 | } 120 | 121 | impl ColorMap { 122 | pub(crate) fn headerbar_paint(&self) -> Paint<'_> { 123 | Paint { 124 | shader: Shader::SolidColor(self.headerbar), 125 | anti_alias: true, 126 | ..Default::default() 127 | } 128 | } 129 | 130 | pub(crate) fn button_idle_paint(&self) -> Paint<'_> { 131 | Paint { 132 | shader: Shader::SolidColor(self.button_idle), 133 | anti_alias: true, 134 | ..Default::default() 135 | } 136 | } 137 | 138 | pub(crate) fn button_hover_paint(&self) -> Paint<'_> { 139 | Paint { 140 | shader: Shader::SolidColor(self.button_hover), 141 | anti_alias: true, 142 | ..Default::default() 143 | } 144 | } 145 | 146 | pub(crate) fn button_icon_paint(&self) -> Paint<'_> { 147 | Paint { 148 | shader: Shader::SolidColor(self.button_icon), 149 | ..Default::default() 150 | } 151 | } 152 | 153 | pub(crate) fn border_paint(&self) -> Paint<'_> { 154 | Paint { 155 | shader: Shader::SolidColor(self.border_color), 156 | ..Default::default() 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/pointer.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use smithay_client_toolkit::reexports::csd_frame::{ 4 | CursorIcon, FrameAction, ResizeEdge, WindowManagerCapabilities, WindowState, 5 | }; 6 | 7 | use crate::{ 8 | buttons::ButtonKind, 9 | theme::{self, HEADER_SIZE}, 10 | }; 11 | 12 | /// Time to register the next click as a double click. 13 | /// 14 | /// The value is the same as the default in gtk4. 15 | const DOUBLE_CLICK_DURATION: Duration = Duration::from_millis(400); 16 | 17 | /// The state of the mouse input inside the decorations frame. 18 | #[derive(Debug, Default)] 19 | pub(crate) struct MouseState { 20 | pub location: Location, 21 | 22 | /// The surface local location inside the surface. 23 | position: (f64, f64), 24 | 25 | /// The instant of the last click. 26 | last_normal_click: Option, 27 | } 28 | 29 | impl MouseState { 30 | /// The normal click on decorations frame was made. 31 | pub fn click( 32 | &mut self, 33 | timestamp: Duration, 34 | pressed: bool, 35 | resizable: bool, 36 | state: &WindowState, 37 | wm_capabilities: &WindowManagerCapabilities, 38 | ) -> Option { 39 | let maximized = state.contains(WindowState::MAXIMIZED); 40 | let action = match self.location { 41 | Location::Top if resizable => FrameAction::Resize(ResizeEdge::Top), 42 | Location::TopLeft if resizable => FrameAction::Resize(ResizeEdge::TopLeft), 43 | Location::Left if resizable => FrameAction::Resize(ResizeEdge::Left), 44 | Location::BottomLeft if resizable => FrameAction::Resize(ResizeEdge::BottomLeft), 45 | Location::Bottom if resizable => FrameAction::Resize(ResizeEdge::Bottom), 46 | Location::BottomRight if resizable => FrameAction::Resize(ResizeEdge::BottomRight), 47 | Location::Right if resizable => FrameAction::Resize(ResizeEdge::Right), 48 | Location::TopRight if resizable => FrameAction::Resize(ResizeEdge::TopRight), 49 | Location::Button(ButtonKind::Close) if !pressed => FrameAction::Close, 50 | Location::Button(ButtonKind::Maximize) if !pressed && !maximized => { 51 | FrameAction::Maximize 52 | } 53 | Location::Button(ButtonKind::Maximize) if !pressed && maximized => { 54 | FrameAction::UnMaximize 55 | } 56 | Location::Button(ButtonKind::Minimize) if !pressed => FrameAction::Minimize, 57 | Location::Head 58 | if pressed && wm_capabilities.contains(WindowManagerCapabilities::MAXIMIZE) => 59 | { 60 | match self.last_normal_click.replace(timestamp) { 61 | Some(last) if timestamp.saturating_sub(last) < DOUBLE_CLICK_DURATION => { 62 | if maximized { 63 | FrameAction::UnMaximize 64 | } else { 65 | FrameAction::Maximize 66 | } 67 | } 68 | _ => FrameAction::Move, 69 | } 70 | } 71 | Location::Head if pressed => FrameAction::Move, 72 | _ => return None, 73 | }; 74 | 75 | Some(action) 76 | } 77 | 78 | /// Alternative click on decorations frame was made. 79 | pub fn alternate_click( 80 | &mut self, 81 | pressed: bool, 82 | wm_capabilities: &WindowManagerCapabilities, 83 | hide_border: bool, 84 | ) -> Option { 85 | // Invalidate the normal click. 86 | self.last_normal_click = None; 87 | 88 | let edge_size = theme::edge_size(hide_border); 89 | 90 | match self.location { 91 | Location::Head | Location::Button(_) 92 | if pressed && wm_capabilities.contains(WindowManagerCapabilities::WINDOW_MENU) => 93 | { 94 | Some(FrameAction::ShowMenu( 95 | // XXX this could be one 1pt off when the frame is not maximized, but it's not 96 | // like it really matters in the end. 97 | self.position.0 as i32 - edge_size as i32, 98 | // We must offset it by header size for precise position. 99 | self.position.1 as i32 - HEADER_SIZE as i32, 100 | )) 101 | } 102 | _ => None, 103 | } 104 | } 105 | 106 | /// The mouse moved inside the decorations frame. 107 | pub fn moved(&mut self, location: Location, x: f64, y: f64, resizable: bool) -> CursorIcon { 108 | self.location = location; 109 | self.position = (x, y); 110 | match self.location { 111 | _ if !resizable => CursorIcon::Default, 112 | Location::Top => CursorIcon::NResize, 113 | Location::TopRight => CursorIcon::NeResize, 114 | Location::Right => CursorIcon::EResize, 115 | Location::BottomRight => CursorIcon::SeResize, 116 | Location::Bottom => CursorIcon::SResize, 117 | Location::BottomLeft => CursorIcon::SwResize, 118 | Location::Left => CursorIcon::WResize, 119 | Location::TopLeft => CursorIcon::NwResize, 120 | _ => CursorIcon::Default, 121 | } 122 | } 123 | 124 | /// The mouse left the decorations frame. 125 | pub fn left(&mut self) { 126 | // Reset only the location. 127 | self.location = Location::None; 128 | } 129 | } 130 | 131 | #[derive(Debug, Copy, Clone, PartialEq, Eq, Default)] 132 | pub enum Location { 133 | #[default] 134 | None, 135 | Head, 136 | Top, 137 | TopRight, 138 | Right, 139 | BottomRight, 140 | Bottom, 141 | BottomLeft, 142 | Left, 143 | TopLeft, 144 | Button(ButtonKind), 145 | } 146 | -------------------------------------------------------------------------------- /src/title/crossfont_renderer.rs: -------------------------------------------------------------------------------- 1 | use std::mem; 2 | 3 | use crossfont::{GlyphKey, Rasterize, RasterizedGlyph}; 4 | use tiny_skia::{Color, Pixmap, PixmapPaint, PixmapRef, Transform}; 5 | 6 | use crate::title::config; 7 | 8 | pub struct CrossfontTitleText { 9 | title: String, 10 | 11 | font_desc: crossfont::FontDesc, 12 | font_key: crossfont::FontKey, 13 | size: crossfont::Size, 14 | scale: u32, 15 | metrics: crossfont::Metrics, 16 | rasterizer: crossfont::Rasterizer, 17 | color: Color, 18 | 19 | pixmap: Option, 20 | } 21 | 22 | impl std::fmt::Debug for CrossfontTitleText { 23 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 24 | f.debug_struct("TitleText") 25 | .field("title", &self.title) 26 | .field("font_desc", &self.font_desc) 27 | .field("font_key", &self.font_key) 28 | .field("size", &self.size) 29 | .field("scale", &self.scale) 30 | .field("pixmap", &self.pixmap) 31 | .finish() 32 | } 33 | } 34 | 35 | impl CrossfontTitleText { 36 | pub fn new(color: Color) -> Result { 37 | let title = "".into(); 38 | 39 | let font_pref = config::titlebar_font().unwrap_or_default(); 40 | let font_style = font_pref 41 | .style 42 | .map(crossfont::Style::Specific) 43 | .unwrap_or_else(|| crossfont::Style::Description { 44 | slant: crossfont::Slant::Normal, 45 | weight: crossfont::Weight::Normal, 46 | }); 47 | let font_desc = crossfont::FontDesc::new(&font_pref.name, font_style); 48 | 49 | let mut rasterizer = crossfont::Rasterizer::new()?; 50 | let size = crossfont::Size::new(font_pref.pt_size); 51 | let font_key = rasterizer.load_font(&font_desc, size)?; 52 | 53 | // Need to load at least one glyph for the face before calling metrics. 54 | // The glyph requested here ('m' at the time of writing) has no special 55 | // meaning. 56 | rasterizer.get_glyph(GlyphKey { 57 | font_key, 58 | character: 'm', 59 | size, 60 | })?; 61 | 62 | let metrics = rasterizer.metrics(font_key, size)?; 63 | 64 | let mut this = Self { 65 | pixmap: None, 66 | rasterizer, 67 | font_desc, 68 | font_key, 69 | scale: 1, 70 | metrics, 71 | title, 72 | color, 73 | size, 74 | }; 75 | 76 | this.rerender(); 77 | 78 | Ok(this) 79 | } 80 | 81 | fn update_metrics(&mut self) -> Result<(), crossfont::Error> { 82 | self.rasterizer.get_glyph(GlyphKey { 83 | font_key: self.font_key, 84 | character: 'm', 85 | size: self.size, 86 | })?; 87 | self.metrics = self.rasterizer.metrics(self.font_key, self.size)?; 88 | Ok(()) 89 | } 90 | 91 | pub fn update_scale(&mut self, scale: u32) { 92 | let old_scale = mem::replace(&mut self.scale, scale); 93 | if old_scale != self.scale { 94 | self.size = self.size.scale(self.scale as f32 / old_scale as f32); 95 | self.update_metrics().ok(); 96 | self.rerender(); 97 | } 98 | } 99 | 100 | pub fn update_title>(&mut self, title: S) { 101 | let title = title.into(); 102 | if self.title != title { 103 | self.title = title; 104 | self.rerender(); 105 | } 106 | } 107 | 108 | pub fn update_color(&mut self, color: Color) { 109 | if self.color != color { 110 | self.color = color; 111 | self.rerender(); 112 | } 113 | } 114 | 115 | fn rerender(&mut self) { 116 | let glyphs: Vec<_> = self 117 | .title 118 | .chars() 119 | .filter_map(|character| { 120 | let key = GlyphKey { 121 | character, 122 | font_key: self.font_key, 123 | size: self.size, 124 | }; 125 | 126 | self.rasterizer 127 | .get_glyph(key) 128 | .map(|glyph| (key, glyph)) 129 | .ok() 130 | }) 131 | .collect(); 132 | 133 | if glyphs.is_empty() { 134 | self.pixmap = None; 135 | return; 136 | } 137 | 138 | let width = self.calc_width(&glyphs); 139 | let height = self.metrics.line_height.round() as i32; 140 | 141 | let mut pixmap = if let Some(p) = Pixmap::new(width as u32, height as u32) { 142 | p 143 | } else { 144 | self.pixmap = None; 145 | return; 146 | }; 147 | // pixmap.fill(Color::from_rgba8(255, 0, 0, 55)); 148 | 149 | let mut caret = 0; 150 | let mut last_glyph = None; 151 | 152 | for (key, glyph) in glyphs { 153 | let mut buffer = Vec::with_capacity(glyph.width as usize * 4); 154 | 155 | let glyph_buffer = match &glyph.buffer { 156 | crossfont::BitmapBuffer::Rgb(v) => v.chunks(3), 157 | crossfont::BitmapBuffer::Rgba(v) => v.chunks(4), 158 | }; 159 | 160 | for px in glyph_buffer { 161 | let alpha = if let Some(alpha) = px.get(3) { 162 | *alpha as f32 / 255.0 163 | } else { 164 | let r = px[0] as f32 / 255.0; 165 | let g = px[1] as f32 / 255.0; 166 | let b = px[2] as f32 / 255.0; 167 | (r + g + b) / 3.0 168 | }; 169 | 170 | let mut color = self.color; 171 | color.set_alpha(alpha); 172 | let color = color.premultiply().to_color_u8(); 173 | 174 | buffer.push(color.red()); 175 | buffer.push(color.green()); 176 | buffer.push(color.blue()); 177 | buffer.push(color.alpha()); 178 | } 179 | 180 | if let Some(last) = last_glyph { 181 | let (x, _) = self.rasterizer.kerning(last, key); 182 | caret += x as i32; 183 | } 184 | 185 | if let Some(pixmap_glyph) = 186 | PixmapRef::from_bytes(&buffer, glyph.width as _, glyph.height as _) 187 | { 188 | pixmap.draw_pixmap( 189 | glyph.left + caret, 190 | height - glyph.top + self.metrics.descent.round() as i32, 191 | pixmap_glyph, 192 | &PixmapPaint::default(), 193 | Transform::identity(), 194 | None, 195 | ); 196 | } 197 | 198 | caret += glyph.advance.0; 199 | 200 | last_glyph = Some(key); 201 | } 202 | 203 | self.pixmap = Some(pixmap); 204 | } 205 | 206 | pub fn pixmap(&self) -> Option<&Pixmap> { 207 | self.pixmap.as_ref() 208 | } 209 | 210 | fn calc_width(&mut self, glyphs: &[(GlyphKey, RasterizedGlyph)]) -> i32 { 211 | let mut caret = 0; 212 | let mut last_glyph: Option<&GlyphKey> = None; 213 | 214 | for (key, glyph) in glyphs.iter() { 215 | if let Some(last) = last_glyph { 216 | let (x, _) = self.rasterizer.kerning(*last, *key); 217 | caret += x as i32; 218 | } 219 | 220 | caret += glyph.advance.0; 221 | 222 | last_glyph = Some(key); 223 | } 224 | 225 | caret 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /src/title/ab_glyph_renderer.rs: -------------------------------------------------------------------------------- 1 | //! Title renderer using ab_glyph. 2 | //! 3 | //! Requires no dynamically linked dependencies. 4 | //! 5 | //! Can fallback to a embedded Cantarell-Regular.ttf font (SIL Open Font Licence v1.1) 6 | //! if the system font doesn't work. 7 | use crate::title::{config, font_preference::FontPreference}; 8 | use ab_glyph::{point, Font, FontRef, Glyph, PxScale, PxScaleFont, ScaleFont, VariableFont}; 9 | use std::{fs::File, process::Command}; 10 | use tiny_skia::{Color, Pixmap, PremultipliedColorU8}; 11 | 12 | const CANTARELL: &[u8] = include_bytes!("Cantarell-Regular.ttf"); 13 | 14 | #[derive(Debug)] 15 | pub struct AbGlyphTitleText { 16 | title: String, 17 | font: Option<(memmap2::Mmap, FontPreference)>, 18 | original_px_size: f32, 19 | size: PxScale, 20 | color: Color, 21 | pixmap: Option, 22 | } 23 | 24 | impl AbGlyphTitleText { 25 | pub fn new(color: Color) -> Self { 26 | let font_pref = config::titlebar_font().unwrap_or_default(); 27 | let font_pref_pt_size = font_pref.pt_size; 28 | let font = font_file_matching(&font_pref) 29 | .and_then(|f| mmap(&f)) 30 | .map(|mmap| (mmap, font_pref)); 31 | 32 | let size = parse_font(&font) 33 | .pt_to_px_scale(font_pref_pt_size) 34 | .unwrap_or_else(|| { 35 | log::error!("invalid font units_per_em"); 36 | PxScale { x: 17.6, y: 17.6 } 37 | }); 38 | 39 | Self { 40 | title: <_>::default(), 41 | font, 42 | original_px_size: size.x, 43 | size, 44 | color, 45 | pixmap: None, 46 | } 47 | } 48 | 49 | pub fn update_scale(&mut self, scale: u32) { 50 | let new_scale = PxScale::from(self.original_px_size * scale as f32); 51 | if (self.size.x - new_scale.x).abs() > f32::EPSILON { 52 | self.size = new_scale; 53 | self.pixmap = self.render(); 54 | } 55 | } 56 | 57 | pub fn update_title(&mut self, title: impl Into) { 58 | let new_title = title.into(); 59 | if new_title != self.title { 60 | self.title = new_title; 61 | self.pixmap = self.render(); 62 | } 63 | } 64 | 65 | pub fn update_color(&mut self, color: Color) { 66 | if color != self.color { 67 | self.color = color; 68 | self.pixmap = self.render(); 69 | } 70 | } 71 | 72 | pub fn pixmap(&self) -> Option<&Pixmap> { 73 | self.pixmap.as_ref() 74 | } 75 | 76 | /// Render returning the new `Pixmap`. 77 | fn render(&self) -> Option { 78 | let font = parse_font(&self.font); 79 | let font = font.as_scaled(self.size); 80 | 81 | let glyphs: Vec<_> = self 82 | .layout(&font) 83 | .into_iter() 84 | .filter_map(|g| font.outline_glyph(g)) 85 | .collect(); 86 | 87 | // calc combined px bound coordinates of the rendered glyphs 88 | // Note: It is possible for min.x to be negative, e.g. the first glyph's 89 | // outline extends a little out to the left further than the layout x origin 0.0 90 | let all_px_bounds = glyphs.iter().map(|g| g.px_bounds()).reduce(|mut b, next| { 91 | b.min.x = b.min.x.min(next.min.x); 92 | b.max.x = b.max.x.max(next.max.x); 93 | // min(0.0): consistently allocate enough for the whole ascent even 94 | // if all glyphs don't need that much, makes positioning easier later. 95 | // If removed the pixmap will be exactly sized, but we'd need a 96 | // vertical offset to render, say "Tg" vs "gg", consistently 97 | b.min.y = b.min.y.min(next.min.y).min(0.0); 98 | b.max.y = b.max.y.max(next.max.y); 99 | b 100 | })?; 101 | 102 | let width = all_px_bounds.width() as _; 103 | let mut pixmap = Pixmap::new(width, all_px_bounds.height() as _)?; 104 | let pixels = pixmap.pixels_mut(); 105 | 106 | for glyph in glyphs { 107 | let bounds = glyph.px_bounds(); 108 | // calc top/left ords in pixmap space 109 | // pixmap-x=0 means the *left most pixel*, equivalent to 110 | // px_bounds.min.x which *may be non-zero* (and similarly with y) 111 | // so `- px_bounds.min` converts the left-most/top-most to 0 112 | let pixmap_left = bounds.min.x as u32 - all_px_bounds.min.x as u32; 113 | let pixmap_top = bounds.min.y as u32 - all_px_bounds.min.y as u32; 114 | glyph.draw(|x, y, c| { 115 | // `ab_glyph` may return values greater than 1.0, but they are defined to be 116 | // same as 1.0. For our purposes, we need to constrain this value. 117 | let c = c.min(1.0); 118 | 119 | let p_idx = (pixmap_top + y) * width + pixmap_left + x; 120 | let Some(pixel) = pixels.get_mut(p_idx as usize) else { 121 | debug_assert!( 122 | false, 123 | "oob pixel: x={x} y={y} top={pixmap_top} left={pixmap_left}, w={width}" 124 | ); 125 | return; 126 | }; 127 | 128 | let old_alpha_u8 = pixel.alpha(); 129 | 130 | let new_alpha = c + (old_alpha_u8 as f32 / 255.0); 131 | if let Some(px) = PremultipliedColorU8::from_rgba( 132 | (self.color.red() * new_alpha * 255.0) as _, 133 | (self.color.green() * new_alpha * 255.0) as _, 134 | (self.color.blue() * new_alpha * 255.0) as _, 135 | (new_alpha * 255.0) as _, 136 | ) { 137 | *pixel = px; 138 | } 139 | }) 140 | } 141 | 142 | Some(pixmap) 143 | } 144 | 145 | /// Simple single-line glyph layout starting from `(0, ascent)`. 146 | fn layout(&self, font: &PxScaleFont) -> Vec { 147 | let mut caret = point(0.0, font.ascent()); 148 | let mut last_glyph: Option = None; 149 | let mut target = Vec::new(); 150 | for c in self.title.chars() { 151 | if c.is_control() { 152 | continue; 153 | } 154 | let mut glyph = font.scaled_glyph(c); 155 | if let Some(previous) = last_glyph.take() { 156 | caret.x += font.kern(previous.id, glyph.id); 157 | } 158 | glyph.position = caret; 159 | 160 | last_glyph = Some(glyph.clone()); 161 | caret.x += font.h_advance(glyph.id); 162 | 163 | target.push(glyph); 164 | } 165 | target 166 | } 167 | } 168 | 169 | /// Parse the memmapped system font or fallback to built-in cantarell. 170 | fn parse_font(sys_font: &Option<(memmap2::Mmap, FontPreference)>) -> FontRef<'_> { 171 | match sys_font { 172 | Some((mmap, font_pref)) => { 173 | FontRef::try_from_slice(mmap) 174 | .map(|mut f| { 175 | // basic "bold" handling for variable fonts 176 | if font_pref 177 | .style 178 | .as_deref() 179 | .is_some_and(|s| s.eq_ignore_ascii_case("bold")) 180 | { 181 | f.set_variation(b"wght", 700.0); 182 | } 183 | f 184 | }) 185 | .unwrap_or_else(|_| { 186 | // We control the default font, so I guess it's fine to unwrap it 187 | #[allow(clippy::unwrap_used)] 188 | FontRef::try_from_slice(CANTARELL).unwrap() 189 | }) 190 | } 191 | // We control the default font, so I guess it's fine to unwrap it 192 | #[allow(clippy::unwrap_used)] 193 | _ => FontRef::try_from_slice(CANTARELL).unwrap(), 194 | } 195 | } 196 | 197 | /// Font-config without dynamically linked dependencies 198 | fn font_file_matching(pref: &FontPreference) -> Option { 199 | let mut pattern = pref.name.clone(); 200 | if let Some(style) = &pref.style { 201 | pattern.push(':'); 202 | pattern.push_str(style); 203 | } 204 | Command::new("fc-match") 205 | .arg("-f") 206 | .arg("%{file}") 207 | .arg(&pattern) 208 | .output() 209 | .ok() 210 | .and_then(|out| String::from_utf8(out.stdout).ok()) 211 | .and_then(|path| File::open(path.trim()).ok()) 212 | } 213 | 214 | fn mmap(file: &File) -> Option { 215 | // Safety: System font files are not expected to be mutated during use 216 | unsafe { memmap2::Mmap::map(file).ok() } 217 | } 218 | -------------------------------------------------------------------------------- /src/buttons.rs: -------------------------------------------------------------------------------- 1 | use log::trace; 2 | use smithay_client_toolkit::reexports::csd_frame::{WindowManagerCapabilities, WindowState}; 3 | use tiny_skia::{FillRule, PathBuilder, PixmapMut, Rect, Stroke, Transform}; 4 | 5 | use crate::{theme::ColorMap, Location, SkiaResult}; 6 | 7 | /// The size of the button on the header bar in logical points. 8 | const BUTTON_SIZE: f32 = 24.; 9 | const BUTTON_MARGIN: f32 = 5.; 10 | const BUTTON_SPACING: f32 = 13.; 11 | 12 | #[derive(Debug)] 13 | pub(crate) struct Buttons { 14 | // Sorted by order vec of buttons for the left and right sides 15 | buttons_left: Vec