Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions nmrs-gui/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ use gtk::Application;
use gtk::prelude::*;

use crate::file_lock::acquire_app_lock;
use crate::style::load_css;
use crate::ui::build_ui;

#[derive(Parser, Debug)]
Expand Down Expand Up @@ -44,7 +43,7 @@ pub fn run() -> anyhow::Result<()> {
};

app.connect_activate(|app| {
load_css();
crate::style::init(include_str!("style.css"));
build_ui(app);
});

Expand Down
111 changes: 76 additions & 35 deletions nmrs-gui/src/style.rs
Original file line number Diff line number Diff line change
@@ -1,55 +1,96 @@
use gtk::gdk::Display;
use gtk::gio::File;
use gtk::{CssProvider, STYLE_PROVIDER_PRIORITY_APPLICATION, STYLE_PROVIDER_PRIORITY_USER};
use gtk::{CssProvider, STYLE_PROVIDER_PRIORITY_USER};
use std::cell::RefCell;
use std::fs;
use std::io::Write;
use std::path::PathBuf;

/// Load and apply the user's custom ~/.config/nmrs/style.css.
/// If the file does not exist it is created with the bundled defaults.
/// This must be called after any theme provider is registered so that
/// same-priority (STYLE_PROVIDER_PRIORITY_USER) rules resolve in favour of
/// the user's stylesheet.
pub fn load_user_css() {
let path = dirs::config_dir()
.unwrap_or_default()
.join("nmrs/style.css");
thread_local! {
static PROVIDER: RefCell<CssProvider> = RefCell::new(CssProvider::new());
}

let display = Display::default().expect("No display found");
fn config_dir() -> PathBuf {
dirs::config_dir().unwrap_or_default().join("nmrs")
}

if path.exists() {
let provider = CssProvider::new();
let file = File::for_path(&path);
fn style_path() -> PathBuf {
config_dir().join("style.css")
}

provider.load_from_file(&file);
fn custom_backup_path() -> PathBuf {
config_dir().join("style.custom.css")
}

/// Register a single persistent CSS provider and load `~/.config/nmrs/style.css`.
/// If it doesn't exist, seeds it with the bundled default.
pub fn init(default_css: &str) {
let display = Display::default().expect("No display found");

PROVIDER.with(|p| {
gtk::style_context_add_provider_for_display(
&display,
&provider,
&*p.borrow(),
STYLE_PROVIDER_PRIORITY_USER,
);
} else {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).ok();
}

let default = include_str!("style.css");
let mut f = fs::File::create(&path).expect("Failed to create CSS file");
f.write_all(default.as_bytes())
.expect("Failed to write default CSS");
});

ensure_dir();
if !style_path().exists() {
write_file(&style_path(), default_css);
}
reload();
}

/// Switch to a named theme: if the user was on "Custom", back up their
/// `style.css` to `style.custom.css` first. Then overwrite `style.css`
/// with the theme content and reload.
pub fn switch_to_theme(css: &str) {
let current = crate::theme_config::load_theme().unwrap_or_default();
if current == "custom" {
backup_custom();
}
write_file(&style_path(), css);
reload();
}

/// Switch to "Custom": restore `style.custom.css` back to `style.css`
/// if a backup exists, then reload.
pub fn switch_to_custom() {
let backup = custom_backup_path();
if backup.exists()
&& let Ok(contents) = fs::read_to_string(&backup)
{
write_file(&style_path(), &contents);
}
reload();
}

pub fn load_css() {
let provider = CssProvider::new();
/// Reload `~/.config/nmrs/style.css` into the persistent provider.
pub fn reload() {
let path = style_path();
if path.exists() {
let file = File::for_path(&path);
PROVIDER.with(|p| {
p.borrow().load_from_file(&file);
});
}
}

let css = include_str!("style.css");
provider.load_from_data(css);
fn backup_custom() {
let src = style_path();
if src.exists() {
fs::copy(&src, custom_backup_path()).ok();
}
}

let display = Display::default().expect("No display found");
fn write_file(path: &PathBuf, contents: &str) {
ensure_dir();
let mut f = fs::File::create(path).expect("Failed to write CSS file");
f.write_all(contents.as_bytes())
.expect("Failed to write CSS file");
}

gtk::style_context_add_provider_for_display(
&display,
&provider,
STYLE_PROVIDER_PRIORITY_APPLICATION,
);
fn ensure_dir() {
fs::create_dir_all(config_dir()).ok();
}
27 changes: 3 additions & 24 deletions nmrs-gui/src/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,14 @@ pub mod wired_page;

use gtk::prelude::*;
use gtk::{
Application, ApplicationWindow, Box as GtkBox, Label, Orientation,
STYLE_PROVIDER_PRIORITY_USER, ScrolledWindow, Spinner, Stack, pango::EllipsizeMode,
Application, ApplicationWindow, Box as GtkBox, Label, Orientation, ScrolledWindow, Spinner,
Stack, pango::EllipsizeMode,
};
use std::cell::Cell;
use std::rc::Rc;
use std::sync::Arc;
use tokio::sync::Notify;

use crate::ui::header::THEMES;

type Callback = Rc<dyn Fn()>;
type CallbackCell = Rc<std::cell::RefCell<Option<Callback>>>;

Expand All @@ -34,26 +32,7 @@ pub fn build_ui(app: &Application) {
let win = ApplicationWindow::new(app);
win.set_title(Some(""));
win.set_default_size(100, 600);

if let Some(key) = crate::theme_config::load_theme()
&& let Some(theme) = THEMES.iter().find(|t| t.key == key.as_str())
{
let provider = gtk::CssProvider::new();
provider.load_from_data(theme.css);

let display = gtk::prelude::RootExt::display(&win);
gtk::style_context_add_provider_for_display(
&display,
&provider,
STYLE_PROVIDER_PRIORITY_USER,
);

win.add_css_class("dark-theme");
}

// User's custom style.css must be registered after the theme so that it
// takes precedence when both run at STYLE_PROVIDER_PRIORITY_USER.
crate::style::load_user_css();
win.add_css_class("dark-theme");

let vbox = GtkBox::new(Orientation::Vertical, 0);
let status = Label::new(None);
Expand Down
70 changes: 36 additions & 34 deletions nmrs-gui/src/ui/settings_page.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use gtk::prelude::*;
use gtk::{Align, Box, Button, Label, Orientation, STYLE_PROVIDER_PRIORITY_USER};
use gtk::{Align, Box, Button, Label, Orientation};

use crate::ui::header::{THEMES, ThemeDef};
use crate::ui::header::THEMES;

const CUSTOM_INDEX: u32 = 0;

pub struct SettingsPage {
root: gtk::Box,
Expand Down Expand Up @@ -33,43 +35,61 @@ impl SettingsPage {
title.set_halign(Align::Start);
root.append(&title);

Self::build_theme_section(&root, window);
Self::build_theme_section(&root);
Self::build_light_dark_section(&root, window);

Self { root }
}

fn build_theme_section(root: &gtk::Box, window: &gtk::ApplicationWindow) {
fn build_theme_section(root: &gtk::Box) {
let section = Box::new(Orientation::Vertical, 6);

let label = Label::new(Some("Theme"));
label.add_css_class("info-label");
label.set_halign(Align::Start);
section.append(&label);

let names: Vec<&str> = THEMES.iter().map(|t| t.name).collect();
let hint = Label::new(Some(
"Your overrides in ~/.config/nmrs/style.css are preserved",
));
hint.add_css_class("info-value");
hint.set_halign(Align::Start);
hint.set_opacity(0.6);
section.append(&hint);

let mut names: Vec<&str> = vec!["Custom"];
names.extend(THEMES.iter().map(|t| t.name));
let dropdown = gtk::DropDown::from_strings(&names);
dropdown.set_halign(Align::Start);
dropdown.set_hexpand(false);

if let Some(saved) = crate::theme_config::load_theme()
&& let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str())
{
dropdown.set_selected(idx as u32);
if let Some(saved) = crate::theme_config::load_theme() {
if let Some(idx) = THEMES.iter().position(|t| t.key == saved.as_str()) {
dropdown.set_selected(idx as u32 + 1);
} else {
dropdown.set_selected(CUSTOM_INDEX);
}
} else {
dropdown.set_selected(CUSTOM_INDEX);
}

let window_weak = window.downgrade();
dropdown.connect_selected_notify(move |dd| {
let idx = dd.selected() as usize;
if idx >= THEMES.len() {
let idx = dd.selected();

if idx == CUSTOM_INDEX {
crate::style::switch_to_custom();
crate::theme_config::save_theme("custom");
return;
}

let theme = &THEMES[idx];

if let Some(window) = window_weak.upgrade() {
Self::apply_theme(theme, &window);
let theme_idx = (idx - 1) as usize;
if theme_idx >= THEMES.len() {
return;
}

let theme = &THEMES[theme_idx];
crate::style::switch_to_theme(theme.css);
crate::theme_config::save_theme(theme.key);
});

section.append(&dropdown);
Expand Down Expand Up @@ -101,7 +121,6 @@ impl SettingsPage {
window.add_css_class("light-theme");
btn.add_css_class("appearance-active");
dark_btn_clone.remove_css_class("appearance-active");
crate::theme_config::save_theme("dark");
}
});
}
Expand All @@ -115,7 +134,6 @@ impl SettingsPage {
window.add_css_class("dark-theme");
btn.add_css_class("appearance-active");
light_btn_clone.remove_css_class("appearance-active");
crate::theme_config::save_theme("light");
}
});
}
Expand All @@ -132,22 +150,6 @@ impl SettingsPage {
root.append(&section);
}

fn apply_theme(theme: &ThemeDef, window: &gtk::ApplicationWindow) {
let provider = gtk::CssProvider::new();
provider.load_from_data(theme.css);

let display = gtk::prelude::RootExt::display(window);

gtk::style_context_add_provider_for_display(
&display,
&provider,
STYLE_PROVIDER_PRIORITY_USER,
);

crate::theme_config::save_theme(theme.key);
crate::style::load_user_css();
}

pub fn widget(&self) -> &gtk::Box {
&self.root
}
Expand Down
2 changes: 1 addition & 1 deletion nmrs-gui/tests/style_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,5 @@ fn style_css_loads() {
}

gtk::init().unwrap();
nmrs_gui::style::load_css();
nmrs_gui::style::init(include_str!("../src/style.css"));
}