Config window: every setting in config.toml, Hyprland options from Hyprland
All tunable values (sizes, timings, text sizes, colours, polls, equalizer presets and bands, hotspot, extra output) are settings declared once in config.rs, read from ~/.config/amane/config.toml, applied live on save. shell.conf's four values move over on first start. settings.rs: a normal window (control centre gear, or ipc config) with a page per part, a page per Hyprland category, search and reset per row. hypr.rs: Hyprland's options from hyprctl descriptions/getoption, applied with hl.config and kept in ~/.config/hypr/amane.lua.
This commit is contained in:
+699
@@ -0,0 +1,699 @@
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::process::Command;
|
||||
|
||||
use amane::{
|
||||
Center, Color, Column, Cursor, End, Full, 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)
|
||||
.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::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)())));
|
||||
}
|
||||
|
||||
let _ = colors;
|
||||
|
||||
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
|
||||
fn choices(option: &HyprOption, colors: &Colors) -> Row {
|
||||
let name = option.name;
|
||||
let chips: Vec<Box<dyn Widget>> = option
|
||||
.choices
|
||||
.iter()
|
||||
.map(|(label, value)| {
|
||||
let chosen = option.current == value.to_string();
|
||||
let value = value.to_string();
|
||||
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 |_| hypr::set(name, &value))
|
||||
.child(Text::new(label.replace('_', " ")).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)
|
||||
}
|
||||
Reference in New Issue
Block a user