appearance.surface.design picks the look (chips in the config window); each design's opacity, rim and light are settings. surface::shell now draws the island, idle pill, workspace and tray pills, tray menu, control centre and popup, so a design applies everywhere. On see-through glass faces fade with .opacity(). Shell text settings can list their allowed words (chips).
729 lines
27 KiB
Rust
729 lines
27 KiB
Rust
use std::cell::RefCell;
|
||
use std::collections::HashMap;
|
||
use std::process::Command;
|
||
|
||
use amane::{
|
||
Center, Color, Column, Cursor, End, Full, Key, Padding, Rectangle, Row, ScrollArea, Service, SpaceBetween, Stack, Start, Text, TextInput, Weight, Widget, Window, children, close_window, open_window, window_size,
|
||
};
|
||
|
||
use crate::config::{self, Kind, Opt};
|
||
use crate::hypr::{self, Hypr, HyprOption};
|
||
use crate::parse::{HyprKind, Value};
|
||
use crate::theme::{self, Colors};
|
||
|
||
/*
|
||
* the config window: every shell setting (config.rs) and every Hyprland option (hypr.rs)
|
||
* on pages in a sidebar, with a search over all of them. a normal window, opened from the
|
||
* control centre's Settings button or `amane ipc call config open|toggle`
|
||
*/
|
||
|
||
const NAME: &str = "magic-island-settings";
|
||
const TITLE: &str = "Magic Island settings";
|
||
|
||
// the window's own layout; its text sizes come from appearance.text like the rest
|
||
const SIDEBAR_W: f32 = 240.0;
|
||
const PAD: f32 = 20.0;
|
||
const CONTROL_W: f32 = 320.0;
|
||
const FIELD_H: f32 = 30.0;
|
||
const SLIDER_W: f32 = 200.0;
|
||
const NUMBER_W: f32 = 104.0;
|
||
const RESET_W: f32 = 56.0;
|
||
|
||
const SEARCH: &str = "settings-search";
|
||
|
||
pub struct Ui {
|
||
// a shell page ("island") or a Hyprland one ("hyprland:decoration")
|
||
page: String,
|
||
query: String,
|
||
message: Option<(String, bool)>,
|
||
hover: Option<String>,
|
||
}
|
||
|
||
impl Service for Ui {
|
||
fn new() -> Self {
|
||
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None }
|
||
}
|
||
|
||
fn listen() {}
|
||
}
|
||
|
||
pub fn open() {
|
||
open_window(NAME, view);
|
||
|
||
hypr::load();
|
||
}
|
||
|
||
// whether the window is open: amane can't say, Hyprland lists it by title
|
||
fn is_open() -> bool {
|
||
Command::new("hyprctl").arg("clients").output().is_ok_and(|done| String::from_utf8_lossy(&done.stdout).contains(&format!("title: {TITLE}\n")))
|
||
}
|
||
|
||
/*
|
||
* `amane ipc call config ...`: open, close, toggle, page <id>, search <words>,
|
||
* get <path>, set <path> <value>, reset <path>, status. paths as in config.toml;
|
||
* hyprland.<option> ones go to Hyprland
|
||
*/
|
||
pub fn command(arguments: &[String]) -> String {
|
||
let words: Vec<&str> = arguments.iter().map(String::as_str).collect();
|
||
|
||
match words.as_slice() {
|
||
["open"] => open(),
|
||
["close"] => close_window(NAME),
|
||
["toggle"] => {
|
||
if is_open() {
|
||
close_window(NAME);
|
||
} else {
|
||
open();
|
||
}
|
||
}
|
||
["page", page] => {
|
||
go_to(page);
|
||
|
||
open();
|
||
}
|
||
["search", rest @ ..] => {
|
||
Ui::write().query = rest.join(" ");
|
||
|
||
open();
|
||
}
|
||
["get", path] => {
|
||
return match config::option(path) {
|
||
Some(option) => config::shown(&config::current(option)),
|
||
None => match path.strip_prefix("hyprland.") {
|
||
Some(key) => Hypr::read().options.iter().find(|option| option.key() == key).map_or_else(|| String::from("not loaded; open the window first"), |option| option.current.clone()),
|
||
None => format!("no setting {path}"),
|
||
},
|
||
};
|
||
}
|
||
["set", path, value @ ..] => return set_text(path, &value.join(" ")).map_or_else(|why| why, |()| String::from("ok")),
|
||
["reset", path] => {
|
||
return match path.strip_prefix("hyprland.") {
|
||
Some(key) => {
|
||
hypr::reset(key);
|
||
|
||
String::from("ok")
|
||
}
|
||
None => config::reset(path).map_or_else(|why| why, |()| String::from("ok")),
|
||
};
|
||
}
|
||
["status"] => {
|
||
let ui = Ui::read();
|
||
|
||
return format!("open={} page={} errors={} search={}", is_open(), ui.page, config::errors().len(), ui.query);
|
||
}
|
||
_ => return String::from("usage: config open|close|toggle|page <id>|search <words>|get <path>|set <path> <value>|reset <path>|status"),
|
||
}
|
||
|
||
String::from("ok")
|
||
}
|
||
|
||
fn go_to(page: &str) {
|
||
let mut ui = Ui::write();
|
||
|
||
ui.page = page.to_string();
|
||
ui.query.clear();
|
||
ui.message = None;
|
||
|
||
if page.starts_with("hyprland:") {
|
||
hypr::load();
|
||
}
|
||
}
|
||
|
||
// a typed value for a setting; Hyprland's are checked here when the list is known, then applied by hypr.rs
|
||
fn set_text(path: &str, text: &str) -> Result<(), String> {
|
||
if let Some(key) = path.strip_prefix("hyprland.") {
|
||
let kind = Hypr::read().options.iter().find(|option| option.key() == key).map(|option| option.kind);
|
||
|
||
if let Some(kind) = kind {
|
||
crate::parse::hypr_lua(kind, text).map_err(|why| format!("{key} {why}"))?;
|
||
}
|
||
|
||
hypr::set(key, text);
|
||
|
||
return Ok(());
|
||
}
|
||
|
||
let option = config::option(path).ok_or(format!("no setting {path}"))?;
|
||
|
||
config::from_text(option, text).and_then(|value| config::set(path, value))
|
||
}
|
||
|
||
fn say(result: Result<(), String>, done: String) {
|
||
Ui::write().message = Some(match result {
|
||
Ok(()) => (done, false),
|
||
Err(why) => (why, true),
|
||
});
|
||
}
|
||
|
||
pub fn view() -> Window {
|
||
theme::begin();
|
||
|
||
let colors = theme::now();
|
||
let ui = Ui::read();
|
||
let hypr = Hypr::read();
|
||
|
||
let (width, height) = match window_size() {
|
||
(0.0, _) | (_, 0.0) => (1100.0, 760.0),
|
||
size => size,
|
||
};
|
||
|
||
let main_w = (width - SIDEBAR_W).max(400.0);
|
||
let row_w = main_w - 2.0 * PAD - 12.0;
|
||
|
||
let query = ui.query.trim().to_lowercase();
|
||
|
||
// the rows to show: a search goes through every page
|
||
let mut sections: Vec<Box<dyn Widget>> = Vec::new();
|
||
|
||
let shell_page = !ui.page.starts_with("hyprland:");
|
||
let category = ui.page.strip_prefix("hyprland:").unwrap_or("");
|
||
|
||
let title = if !query.is_empty() {
|
||
format!("Search: {}", ui.query.trim())
|
||
} else if shell_page {
|
||
config::title(&ui.page)
|
||
} else {
|
||
format!("Hyprland · {}", config::title(category))
|
||
};
|
||
|
||
let mut notes: Vec<(String, bool)> = Vec::new();
|
||
|
||
if shell_page || !query.is_empty() {
|
||
notes.extend(config::errors().into_iter().map(|error| (format!("config.toml {error}"), true)));
|
||
}
|
||
|
||
if (!shell_page || !query.is_empty()) && hypr.loaded && !hypr.required && !config::overrides().is_empty() {
|
||
notes.push((String::from("Hyprland options set here apply now, but a Hyprland reload drops them: add require(\"amane\") as the last line of ~/.config/hypr/hyprland.lua"), false));
|
||
}
|
||
|
||
for note in notes {
|
||
sections.push(Box::new(banner(¬e.0, note.1, row_w)));
|
||
}
|
||
|
||
// shell settings, by group
|
||
let mut group = "";
|
||
let mut page = "";
|
||
|
||
for option in config::OPTIONS {
|
||
let shown = if query.is_empty() { shell_page && option.module() == ui.page } else { option.path.contains(&query) || option.help.to_lowercase().contains(&query) };
|
||
|
||
if !shown {
|
||
continue;
|
||
}
|
||
|
||
if option.group() != group || option.module() != page {
|
||
let heading = if query.is_empty() { config::title(option.group()) } else { format!("{} › {}", config::title(option.module()), config::title(option.group())) };
|
||
|
||
sections.push(Box::new(heading_row(&heading, row_w)));
|
||
|
||
group = option.group();
|
||
page = option.module();
|
||
}
|
||
|
||
sections.push(Box::new(shell_row(option, row_w, &colors)));
|
||
}
|
||
|
||
// Hyprland's, by group
|
||
if !shell_page || !query.is_empty() {
|
||
if !hypr.loaded {
|
||
sections.push(Box::new(Text::new("Reading Hyprland's options…").size(config::appearance::text::body()).color(colors.dim)));
|
||
}
|
||
|
||
let mut group: Option<(&str, &str)> = None;
|
||
|
||
for option in &hypr.options {
|
||
let shown = if query.is_empty() { option.category() == category } else { option.name.contains(&query) || option.help.to_lowercase().contains(&query) };
|
||
|
||
if !shown {
|
||
continue;
|
||
}
|
||
|
||
let here = (option.category(), option.group());
|
||
|
||
if group != Some(here) {
|
||
let named = if option.group().is_empty() { String::new() } else { config::title(option.group()) };
|
||
|
||
let heading = match (query.is_empty(), named.is_empty()) {
|
||
(true, true) => String::from("General"),
|
||
(true, false) => named,
|
||
(false, true) => format!("Hyprland › {}", config::title(option.category())),
|
||
(false, false) => format!("Hyprland › {} › {named}", config::title(option.category())),
|
||
};
|
||
|
||
sections.push(Box::new(heading_row(&heading, row_w)));
|
||
|
||
group = Some(here);
|
||
}
|
||
|
||
sections.push(Box::new(hypr_row(option, row_w, &colors)));
|
||
}
|
||
}
|
||
|
||
if sections.is_empty() {
|
||
sections.push(Box::new(Text::new("Nothing matches.").size(config::appearance::text::body()).color(colors.dim)));
|
||
}
|
||
|
||
let message = if shell_page { ui.message.clone() } else { hypr.message.clone().or(ui.message.clone()) };
|
||
|
||
let (words, wrong) = message.unwrap_or_else(|| (format!("Saved to {} as you change things; Enter applies a typed value", config::file().display()), false));
|
||
|
||
let footer = Text::new(words).size(config::appearance::text::small()).color(if wrong { config::appearance::overlays::low_battery() } else { colors.dim }).elide();
|
||
|
||
let main = Rectangle::new()
|
||
.width(main_w)
|
||
.height(height)
|
||
.padding(Padding { top: PAD, right: PAD, bottom: 12.0, left: PAD })
|
||
.child(Column::new(children![
|
||
Text::new(title).size(config::appearance::text::title()).weight(Weight::Bold).color(colors.text),
|
||
ScrollArea::new("settings-rows", Column::new(sections).gap(6.0)).width(main_w - 2.0 * PAD).height(height - PAD - 12.0 - 40.0 - 24.0),
|
||
Rectangle::new().width(main_w - 2.0 * PAD).height(20.0).align_child(Start, Center).child(footer),
|
||
]).gap(10.0));
|
||
|
||
Window::new()
|
||
.title(TITLE)
|
||
.size(1100.0, 760.0)
|
||
.resizable(true)
|
||
// Escape closes it, also from a field (a typed value only applies on Enter)
|
||
.on_key(|key| {
|
||
if key == Key::Escape {
|
||
close_window(NAME);
|
||
}
|
||
})
|
||
.child(
|
||
Rectangle::new()
|
||
.width(width)
|
||
.height(height)
|
||
.fill(colors.surface_low)
|
||
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])),
|
||
)
|
||
}
|
||
|
||
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
|
||
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
|
||
|
||
for module in config::modules() {
|
||
entries.push(Box::new(entry(module.to_string(), config::title(module), ui, colors)));
|
||
}
|
||
|
||
entries.push(Box::new(caption("HYPRLAND", colors)));
|
||
|
||
let mut categories: Vec<&str> = Vec::new();
|
||
|
||
for option in &hypr.options {
|
||
if !categories.contains(&option.category()) {
|
||
categories.push(option.category());
|
||
}
|
||
}
|
||
|
||
if categories.is_empty() {
|
||
entries.push(Box::new(Text::new(if hypr.loaded { "Hyprland didn't answer" } else { "Reading…" }).size(config::appearance::text::small()).color(colors.dim)));
|
||
}
|
||
|
||
for category in categories {
|
||
entries.push(Box::new(entry(format!("hyprland:{category}"), config::title(category), ui, colors)));
|
||
}
|
||
|
||
synced(SEARCH, &ui.query);
|
||
|
||
let search = Rectangle::new()
|
||
.width(SIDEBAR_W - 24.0)
|
||
.height(FIELD_H)
|
||
.radius(10.0)
|
||
.fill(config::appearance::overlays::chip())
|
||
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||
.align_child(Start, Center)
|
||
.cursor(Cursor::Text)
|
||
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
|
||
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
|
||
|
||
Ui::write().query = text;
|
||
}));
|
||
|
||
Rectangle::new()
|
||
.width(SIDEBAR_W)
|
||
.height(height)
|
||
.fill(colors.surface)
|
||
.padding(12.0)
|
||
.child(Column::new(children![search, ScrollArea::new("settings-pages", Column::new(entries).gap(2.0)).width(SIDEBAR_W - 24.0).height(height - 24.0 - FIELD_H - 12.0)]).gap(12.0))
|
||
}
|
||
|
||
fn caption(text: &str, colors: &Colors) -> Rectangle {
|
||
Rectangle::new()
|
||
.width(SIDEBAR_W - 24.0)
|
||
.height(30.0)
|
||
.padding(Padding { top: 12.0, right: 0.0, bottom: 0.0, left: 10.0 })
|
||
.child(Text::new(text).size(config::appearance::text::tiny()).weight(Weight::Bold).color(colors.dim))
|
||
}
|
||
|
||
fn entry(id: String, title: String, ui: &Ui, colors: &Colors) -> Rectangle {
|
||
let chosen = ui.query.is_empty() && ui.page == id;
|
||
let lit = ui.hover.as_deref() == Some(id.as_str());
|
||
|
||
let fill = if chosen { colors.accent } else if lit { config::appearance::overlays::chip_hover() } else { Color::TRANSPARENT };
|
||
|
||
let (hovered, clicked) = (id.clone(), id);
|
||
|
||
Rectangle::new()
|
||
.width(SIDEBAR_W - 24.0)
|
||
.height(30.0)
|
||
.radius(8.0)
|
||
.fill(fill)
|
||
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||
.align_child(Start, Center)
|
||
.cursor(Cursor::Pointer)
|
||
.on_hover(move |inside| {
|
||
let mut ui = Ui::write();
|
||
|
||
if inside {
|
||
ui.hover = Some(hovered.clone());
|
||
} else if ui.hover.as_deref() == Some(hovered.as_str()) {
|
||
ui.hover = None;
|
||
}
|
||
})
|
||
.on_click(move |_| {
|
||
SYNCED.with_borrow_mut(|synced| synced.remove(SEARCH));
|
||
|
||
go_to(&clicked);
|
||
})
|
||
.child(Text::new(title).size(config::appearance::text::body()).color(if chosen { colors.on_accent } else { colors.text }).elide())
|
||
}
|
||
|
||
fn heading_row(text: &str, width: f32) -> Rectangle {
|
||
Rectangle::new()
|
||
.width(width)
|
||
.height(34.0)
|
||
.padding(Padding { top: 14.0, right: 0.0, bottom: 0.0, left: 4.0 })
|
||
.child(Text::new(text.to_uppercase()).size(config::appearance::text::small()).weight(Weight::Bold).color(theme::now().accent))
|
||
}
|
||
|
||
fn banner(text: &str, wrong: bool, width: f32) -> Rectangle {
|
||
let colors = theme::now();
|
||
let words = Text::new(text).size(config::appearance::text::small()).color(colors.text).wrap();
|
||
let height = words.height_in(width - 24.0) + 16.0;
|
||
|
||
Rectangle::new()
|
||
.width(width)
|
||
.height(height)
|
||
.radius(10.0)
|
||
.fill(if wrong { faded(config::appearance::overlays::low_battery(), 60) } else { config::appearance::overlays::chip() })
|
||
.padding(Padding { top: 8.0, right: 12.0, bottom: 8.0, left: 12.0 })
|
||
.child(words)
|
||
}
|
||
|
||
fn faded(color: Color, alpha: u8) -> Color {
|
||
Color::rgba(color.red(), color.green(), color.blue(), alpha)
|
||
}
|
||
|
||
/*
|
||
* one setting: its label and key, the help under them, the control on the right, and
|
||
* Reset while it differs from its default
|
||
*/
|
||
fn row(label: String, key: String, help: String, control: Box<dyn Widget>, reset: Option<Box<dyn Fn()>>, width: f32) -> Rectangle {
|
||
let colors = theme::now();
|
||
let text_w = width - CONTROL_W - RESET_W - 2.0 * 14.0 - 24.0;
|
||
|
||
let help = Text::new(help).size(config::appearance::text::small()).color(colors.dim).wrap();
|
||
let help_h = help.height_in(text_w);
|
||
|
||
let label_h = config::appearance::text::body() * 1.5;
|
||
let height = (label_h + 2.0 + help_h).max(FIELD_H) + 20.0;
|
||
|
||
let reset: Box<dyn Widget> = match reset {
|
||
Some(reset) => Box::new(
|
||
Rectangle::new()
|
||
.width(RESET_W)
|
||
.height(24.0)
|
||
.radius(Full)
|
||
.fill(config::appearance::overlays::chip())
|
||
.align_child(Center, Center)
|
||
.cursor(Cursor::Pointer)
|
||
.on_click(move |_| reset())
|
||
.child(Text::new("Reset").size(config::appearance::text::tiny()).color(colors.text)),
|
||
),
|
||
None => Box::new(Rectangle::new().width(RESET_W).height(24.0)),
|
||
};
|
||
|
||
Rectangle::new()
|
||
.width(width)
|
||
.height(height)
|
||
.radius(12.0)
|
||
.fill(config::appearance::overlays::chip())
|
||
.padding(Padding { top: 10.0, right: 14.0, bottom: 10.0, left: 14.0 })
|
||
.child(
|
||
Row::new(children![
|
||
Column::new(children![
|
||
Row::new(children![
|
||
Text::new(label).size(config::appearance::text::body()).weight(Weight::Bold).color(colors.text),
|
||
Text::new(key).size(config::appearance::text::tiny()).color(colors.dim).elide(),
|
||
])
|
||
.gap(10.0)
|
||
.align(Center)
|
||
.width(text_w)
|
||
.height(label_h),
|
||
Rectangle::new().width(text_w).height(help_h).child(help),
|
||
])
|
||
.gap(2.0),
|
||
Row::new(vec![control, reset]).gap(12.0).align(Center).justify(End).width(CONTROL_W + RESET_W + 12.0),
|
||
])
|
||
.justify(SpaceBetween)
|
||
.align(Center)
|
||
.width(width - 28.0),
|
||
)
|
||
}
|
||
|
||
fn shell_row(option: &'static Opt, width: f32, colors: &Colors) -> Rectangle {
|
||
let value = config::current(option);
|
||
|
||
let control: Box<dyn Widget> = match (option.kind, &value) {
|
||
(Kind::Bool, Value::Bool(on)) => {
|
||
let on = *on;
|
||
|
||
Box::new(switch(on, move || say(config::set(option.path, Value::Bool(!on)), format!("{} = {}", option.path, !on))))
|
||
}
|
||
(Kind::Text, Value::Text(current)) if !option.choices.is_empty() => Box::new(chips(
|
||
option
|
||
.choices
|
||
.iter()
|
||
.map(|&word| (String::from(word), current == word, Box::new(move || say(config::set(option.path, Value::Text(String::from(word))), format!("{} = {word}", option.path))) as Box<dyn Fn()>))
|
||
.collect(),
|
||
colors,
|
||
)),
|
||
(Kind::Int | Kind::Float, _) if option.range.is_some() => {
|
||
let (min, max) = option.range.unwrap_or((0.0, 1.0));
|
||
let whole = option.kind == Kind::Int;
|
||
let number = value.number().unwrap_or(min);
|
||
|
||
Box::new(
|
||
Row::new(children![
|
||
slider((number - min) / (max - min), move |fraction| {
|
||
let picked = stepped(min + fraction * (max - min), min, max, whole);
|
||
let value = if whole { Value::Int(picked as i64) } else { Value::Float(picked) };
|
||
|
||
say(config::set(option.path, value), format!("{} = {}", option.path, config::shown(&if whole { Value::Int(picked as i64) } else { Value::Float(picked) })));
|
||
}),
|
||
field(option.path, &config::shown(&value), NUMBER_W, None, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))),
|
||
])
|
||
.gap(12.0)
|
||
.align(Center),
|
||
)
|
||
}
|
||
_ => {
|
||
let swatch = (option.kind == Kind::Color).then(|| value.text().and_then(config::color)).flatten();
|
||
|
||
Box::new(field(option.path, &config::shown(&value), CONTROL_W, swatch, move |text| say(set_text(option.path, &text), format!("{} = {text}", option.path))))
|
||
}
|
||
};
|
||
|
||
let reset: Option<Box<dyn Fn()>> = (!config::is_default(option)).then(|| Box::new(move || say(config::reset(option.path), format!("{} is back to its default", option.path))) as Box<dyn Fn()>);
|
||
|
||
let mut help = option.help.to_string();
|
||
|
||
if !config::is_default(option) {
|
||
help.push_str(&format!(" · default {}", config::shown(&(option.default)())));
|
||
}
|
||
|
||
row(config::label(option.name()), option.path.to_string(), help, control, reset, width)
|
||
}
|
||
|
||
fn hypr_row(option: &HyprOption, width: f32, colors: &Colors) -> Rectangle {
|
||
let name = option.name;
|
||
let path = option.path();
|
||
let overridden = config::override_of(&path).is_some();
|
||
|
||
let control: Box<dyn Widget> = match option.kind {
|
||
HyprKind::Bool => {
|
||
let on = option.current == "true";
|
||
|
||
Box::new(switch(on, move || hypr::set(name, if on { "false" } else { "true" })))
|
||
}
|
||
HyprKind::Int if !option.choices.is_empty() => Box::new(choices(option, colors)),
|
||
HyprKind::Int | HyprKind::Float if option.range.is_some() => {
|
||
let (min, max) = option.range.unwrap_or((0.0, 1.0));
|
||
let whole = option.kind == HyprKind::Int;
|
||
let number: f64 = option.current.parse().unwrap_or(min);
|
||
|
||
Box::new(
|
||
Row::new(children![
|
||
slider((number - min) / (max - min), move |fraction| {
|
||
let picked = stepped(min + fraction * (max - min), min, max, whole);
|
||
|
||
hypr::set(name, &if whole { (picked as i64).to_string() } else { picked.to_string() });
|
||
}),
|
||
field(name, &option.current, NUMBER_W, None, move |text| hypr::set(name, &text)),
|
||
])
|
||
.gap(12.0)
|
||
.align(Center),
|
||
)
|
||
}
|
||
_ => {
|
||
let swatch = (option.kind == HyprKind::Gradient).then(|| first_color(&option.current)).flatten();
|
||
|
||
Box::new(field(name, &option.current, CONTROL_W, swatch, move |text| hypr::set(name, &text)))
|
||
}
|
||
};
|
||
|
||
let reset: Option<Box<dyn Fn()>> = overridden.then(|| Box::new(move || hypr::reset(name)) as Box<dyn Fn()>);
|
||
|
||
let mut help = option.help.clone();
|
||
|
||
if !option.default.is_empty() {
|
||
help.push_str(&format!(" · default {}", option.default));
|
||
}
|
||
|
||
if overridden {
|
||
help.push_str(" · set here");
|
||
} else if option.in_config {
|
||
help.push_str(" · set in your Hyprland config");
|
||
}
|
||
|
||
let label = option.label().replace("col.", "colour_").replace('.', "_");
|
||
|
||
row(config::label(&label), path, help, control, reset, width)
|
||
}
|
||
|
||
// "rgba(80d9ffff) ..." as its first colour, for the swatch
|
||
fn first_color(gradient: &str) -> Option<Color> {
|
||
let word = gradient.split_whitespace().next()?;
|
||
|
||
config::color(word.trim_start_matches("rgba(").trim_end_matches(')'))
|
||
}
|
||
|
||
// a value from a slider: whole numbers, or steps of about a hundredth of the range
|
||
fn stepped(value: f64, min: f64, max: f64, whole: bool) -> f64 {
|
||
let value = value.clamp(min, max);
|
||
|
||
if whole {
|
||
return value.round();
|
||
}
|
||
|
||
let step = 10f64.powf(((max - min).log10()).floor() - 2.0);
|
||
|
||
((value / step).round() * step * 1e6).round() / 1e6
|
||
}
|
||
|
||
fn switch(on: bool, toggle: impl Fn() + 'static) -> Rectangle {
|
||
let colors = theme::now();
|
||
|
||
Rectangle::new()
|
||
.width(46.0)
|
||
.height(26.0)
|
||
.radius(Full)
|
||
.fill(if on { colors.accent } else { config::appearance::overlays::track() })
|
||
.padding(4.0)
|
||
.align_child(Start, Center)
|
||
.cursor(Cursor::Pointer)
|
||
.on_click(move |_| toggle())
|
||
.child(Rectangle::new().width(18.0).height(18.0).radius(Full).fill(if on { colors.on_accent } else { colors.dim }).translate(if on { 20.0 } else { 0.0 }, 0.0))
|
||
}
|
||
|
||
fn slider(fraction: f64, pick: impl Fn(f64) + Clone + 'static) -> Rectangle {
|
||
let colors = theme::now();
|
||
let fraction = fraction.clamp(0.0, 1.0) as f32;
|
||
let knob = 16.0;
|
||
|
||
let pick_drag = pick.clone();
|
||
|
||
Rectangle::new()
|
||
.width(SLIDER_W)
|
||
.height(FIELD_H)
|
||
.align_child(Start, Center)
|
||
.cursor(Cursor::Pointer)
|
||
.on_click(|_| {})
|
||
.on_drag(move |point| pick_drag(f64::from(((point.x - knob / 2.0) / (SLIDER_W - knob)).clamp(0.0, 1.0))))
|
||
.on_scroll(move |scroll| pick(f64::from((fraction + if scroll.y < 0.0 { 0.01 } else { -0.01 }).clamp(0.0, 1.0))))
|
||
.child(Stack::new(children![
|
||
Rectangle::new().width(SLIDER_W).height(6.0).radius(Full).fill(config::appearance::overlays::track()).translate(0.0, (knob - 6.0) / 2.0),
|
||
Rectangle::new().width(knob / 2.0 + fraction * (SLIDER_W - knob)).height(6.0).radius(Full).fill(colors.accent).translate(0.0, (knob - 6.0) / 2.0),
|
||
Rectangle::new().width(knob).height(knob).radius(Full).fill(colors.flame).translate(fraction * (SLIDER_W - knob), 0.0),
|
||
]).width(SLIDER_W).height(knob))
|
||
}
|
||
|
||
// a Hyprland number with named values: one chip per name
|
||
// a Hyprland option's named values as chips
|
||
fn choices(option: &HyprOption, colors: &Colors) -> Row {
|
||
let name = option.name;
|
||
|
||
chips(
|
||
option
|
||
.choices
|
||
.iter()
|
||
.map(|(label, value)| {
|
||
let value = value.to_string();
|
||
|
||
(label.replace('_', " "), option.current == value, Box::new(move || hypr::set(name, &value)) as Box<dyn Fn()>)
|
||
})
|
||
.collect(),
|
||
colors,
|
||
)
|
||
}
|
||
|
||
// one choice: its label, whether it's the current one, what a click does
|
||
type Choice = (String, bool, Box<dyn Fn()>);
|
||
|
||
fn chips(items: Vec<Choice>, colors: &Colors) -> Row {
|
||
let chips: Vec<Box<dyn Widget>> = items
|
||
.into_iter()
|
||
.map(|(label, chosen, pick)| {
|
||
let letters = label.chars().count() as f32;
|
||
|
||
Box::new(
|
||
Rectangle::new()
|
||
.width(letters * config::appearance::text::small() * config::appearance::font::letter_width() + 18.0)
|
||
.height(26.0)
|
||
.radius(Full)
|
||
.fill(if chosen { colors.accent } else { config::appearance::overlays::chip() })
|
||
.align_child(Center, Center)
|
||
.cursor(Cursor::Pointer)
|
||
.on_click(move |_| pick())
|
||
.child(Text::new(label).size(config::appearance::text::small()).color(if chosen { colors.on_accent } else { colors.text })),
|
||
) as Box<dyn Widget>
|
||
})
|
||
.collect();
|
||
|
||
Row::new(chips).gap(6.0).align(Center).justify(End).width(CONTROL_W)
|
||
}
|
||
|
||
// the text inputs' texts as last set from their values, so typing isn't overwritten until a value changes
|
||
thread_local! {
|
||
static SYNCED: RefCell<HashMap<&'static str, String>> = RefCell::new(HashMap::new());
|
||
}
|
||
|
||
fn synced(id: &'static str, text: &str) {
|
||
SYNCED.with_borrow_mut(|synced| {
|
||
if synced.get(id).map(String::as_str) != Some(text) {
|
||
TextInput::set_text(id, text);
|
||
|
||
synced.insert(id, text.to_string());
|
||
}
|
||
});
|
||
}
|
||
|
||
fn field(id: &'static str, text: &str, width: f32, swatch: Option<Color>, submit: impl Fn(String) + 'static) -> Row {
|
||
let colors = theme::now();
|
||
|
||
synced(id, text);
|
||
|
||
let swatch_w = if swatch.is_some() { FIELD_H + 8.0 } else { 0.0 };
|
||
|
||
let mut parts: Vec<Box<dyn Widget>> = Vec::new();
|
||
|
||
if let Some(color) = swatch {
|
||
parts.push(Box::new(Rectangle::new().width(FIELD_H).height(FIELD_H).radius(8.0).fill(color).border(1.0, colors.outline)));
|
||
}
|
||
|
||
parts.push(Box::new(
|
||
Rectangle::new()
|
||
.width(width - swatch_w)
|
||
.height(FIELD_H)
|
||
.radius(8.0)
|
||
.fill(config::appearance::overlays::chip())
|
||
.border(1.0, colors.outline)
|
||
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
|
||
.align_child(Start, Center)
|
||
.cursor(Cursor::Text)
|
||
.child(TextInput::new(id).size(config::appearance::text::body()).color(colors.text).on_submit(submit)),
|
||
));
|
||
|
||
Row::new(parts).gap(8.0).align(Center)
|
||
}
|