This commit is contained in:
2026-08-02 13:11:42 +03:00
parent 4f043fc1c0
commit 0e40f7f1cd
14 changed files with 716 additions and 219 deletions
+248
View File
@@ -0,0 +1,248 @@
//! Persistent app settings (`~/.config/rcalc/config.json` or `%APPDATA%\rcalc\config.json`).
use std::fs;
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThemeId {
Dark = 0,
Light = 1,
Blue = 2,
}
impl ThemeId {
pub fn from_i32(v: i32) -> Self {
match v {
1 => Self::Light,
2 => Self::Blue,
_ => Self::Dark,
}
}
pub fn as_i32(self) -> i32 {
self as i32
}
pub fn label(self) -> &'static str {
match self {
Self::Dark => "Dark",
Self::Light => "Light",
Self::Blue => "Blue",
}
}
}
#[derive(Debug, Clone)]
pub struct AppConfig {
/// Button size: 1 = compact, 2 = normal, 3 = large.
pub button_size: u8,
pub theme: ThemeId,
/// Last calculator mode index (0..4).
pub last_mode: u8,
pub show_bits: bool,
/// Programmer word size label: QWORD, DWORD, DINT, WORD, INT, BYTE.
pub word_size: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
button_size: 2,
theme: ThemeId::Dark,
last_mode: 0,
show_bits: true,
word_size: "QWORD".into(),
}
}
}
impl AppConfig {
pub fn load() -> Self {
let path = config_path();
let Ok(raw) = fs::read_to_string(&path) else {
return Self::default();
};
Self::from_json(&raw).unwrap_or_default()
}
pub fn save(&self) {
let path = config_path();
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let _ = fs::write(path, self.to_json());
}
pub fn clamp_button_size(size: u8) -> u8 {
size.clamp(1, 3)
}
fn to_json(&self) -> String {
format!(
"{{\n \"button_size\": {},\n \"theme\": {},\n \"last_mode\": {},\n \"show_bits\": {},\n \"word_size\": \"{}\"\n}}\n",
self.button_size,
self.theme.as_i32(),
self.last_mode,
self.show_bits,
self.word_size
)
}
fn from_json(raw: &str) -> Option<Self> {
let mut cfg = Self::default();
for line in raw.lines() {
let line = line.trim().trim_end_matches(',');
if let Some(v) = json_u8(line, "button_size") {
cfg.button_size = Self::clamp_button_size(v);
} else if let Some(v) = json_i32(line, "theme") {
cfg.theme = ThemeId::from_i32(v);
} else if let Some(v) = json_u8(line, "last_mode") {
cfg.last_mode = v.min(4);
} else if let Some(v) = json_bool(line, "show_bits") {
cfg.show_bits = v;
} else if let Some(v) = json_string(line, "word_size") {
let upper = v.to_ascii_uppercase();
if matches!(
upper.as_str(),
"QWORD" | "DWORD" | "DINT" | "WORD" | "INT" | "BYTE"
) {
cfg.word_size = upper;
}
}
}
Some(cfg)
}
}
fn json_u8(line: &str, key: &str) -> Option<u8> {
json_i32(line, key)?.try_into().ok()
}
fn json_i32(line: &str, key: &str) -> Option<i32> {
let prefix = format!("\"{key}\"");
let idx = line.find(&prefix)?;
let rest = &line[idx + prefix.len()..];
let rest = rest.trim_start().trim_start_matches(':').trim_start();
rest.split(|c: char| c == ',' || c == '}' || c.is_whitespace())
.next()?
.parse()
.ok()
}
fn json_bool(line: &str, key: &str) -> Option<bool> {
let prefix = format!("\"{key}\"");
let idx = line.find(&prefix)?;
let rest = &line[idx + prefix.len()..];
let rest = rest.trim_start().trim_start_matches(':').trim_start();
if rest.starts_with("true") {
Some(true)
} else if rest.starts_with("false") {
Some(false)
} else {
None
}
}
fn json_string(line: &str, key: &str) -> Option<String> {
let prefix = format!("\"{key}\"");
let idx = line.find(&prefix)?;
let rest = &line[idx + prefix.len()..];
let rest = rest.trim_start().trim_start_matches(':').trim_start();
let rest = rest.strip_prefix('"')?;
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
pub fn config_path() -> PathBuf {
config_dir().join("config.json")
}
fn config_dir() -> PathBuf {
#[cfg(windows)]
{
if let Some(appdata) = std::env::var_os("APPDATA") {
return PathBuf::from(appdata).join("rcalc");
}
}
#[cfg(not(windows))]
{
if let Some(xdg) = std::env::var_os("XDG_CONFIG_HOME") {
return PathBuf::from(xdg).join("rcalc");
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home).join(".config").join("rcalc");
}
}
PathBuf::from(".")
}
/// RGB colors for a theme (applied to Slint global).
pub struct ThemeColors {
pub bg: (u8, u8, u8),
pub surface: (u8, u8, u8),
pub btn: (u8, u8, u8),
pub btn_hover: (u8, u8, u8),
pub digit: (u8, u8, u8),
pub digit_hover: (u8, u8, u8),
pub accent: (u8, u8, u8),
pub accent_hover: (u8, u8, u8),
pub text: (u8, u8, u8),
pub text_muted: (u8, u8, u8),
pub text_dim: (u8, u8, u8),
pub menu_active: (u8, u8, u8),
pub eq_text: (u8, u8, u8),
pub border: (u8, u8, u8),
}
pub fn theme_colors(id: ThemeId) -> ThemeColors {
match id {
ThemeId::Dark => ThemeColors {
bg: (0x20, 0x20, 0x20),
surface: (0x2a, 0x2a, 0x2a),
btn: (0x32, 0x32, 0x32),
btn_hover: (0x46, 0x46, 0x46),
digit: (0x3b, 0x3b, 0x3b),
digit_hover: (0x50, 0x50, 0x50),
accent: (0x60, 0xcd, 0xff),
accent_hover: (0x78, 0xd7, 0xff),
text: (0xff, 0xff, 0xff),
text_muted: (0xc8, 0xc8, 0xc8),
text_dim: (0x9a, 0x9a, 0x9a),
menu_active: (0x2f, 0x3a, 0x42),
eq_text: (0x14, 0x14, 0x14),
border: (0x40, 0x40, 0x40),
},
ThemeId::Light => ThemeColors {
bg: (0xf3, 0xf3, 0xf3),
surface: (0xff, 0xff, 0xff),
btn: (0xe6, 0xe6, 0xe6),
btn_hover: (0xd0, 0xd0, 0xd0),
digit: (0xff, 0xff, 0xff),
digit_hover: (0xee, 0xee, 0xee),
accent: (0x00, 0x78, 0xd4),
accent_hover: (0x1a, 0x86, 0xda),
text: (0x1a, 0x1a, 0x1a),
text_muted: (0x55, 0x55, 0x55),
text_dim: (0x77, 0x77, 0x77),
menu_active: (0xd6, 0xeb, 0xf8),
eq_text: (0xff, 0xff, 0xff),
border: (0xcc, 0xcc, 0xcc),
},
ThemeId::Blue => ThemeColors {
bg: (0x0f, 0x1c, 0x2e),
surface: (0x16, 0x28, 0x40),
btn: (0x1e, 0x3a, 0x5f),
btn_hover: (0x2a, 0x4d, 0x7a),
digit: (0x24, 0x45, 0x6e),
digit_hover: (0x2f, 0x58, 0x88),
accent: (0x4f, 0xc3, 0xf7),
accent_hover: (0x81, 0xd4, 0xfa),
text: (0xec, 0xf5, 0xff),
text_muted: (0xb0, 0xc4, 0xde),
text_dim: (0x7f, 0x9b, 0xb8),
menu_active: (0x1a, 0x3a, 0x5c),
eq_text: (0x0a, 0x14, 0x22),
border: (0x2a, 0x4a, 0x6e),
},
}
}
+168 -39
View File
@@ -1,6 +1,7 @@
// Hide the extra console window on Windows GUI builds.
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
mod config;
mod convert;
mod crc;
mod engine;
@@ -10,11 +11,12 @@ mod programmer;
use std::cell::RefCell;
use std::rc::Rc;
use config::{theme_colors, AppConfig, ThemeId};
use convert::{ConvField, ConvPanel, Converter, Endian, RatioWidth};
use crc::CrcTool;
use engine::{Calculator, Op, StdPanel};
use engineering::Engineering;
use programmer::{Base, ProgOp, Programmer};
use programmer::{Base, ProgOp, Programmer, WordSize};
use slint::{ComponentHandle, ModelRc, VecModel};
slint::include_modules!();
@@ -26,6 +28,42 @@ enum Mode {
Programmer,
Convert,
Crc,
Settings,
}
impl Mode {
fn from_u8(v: u8) -> Self {
match v {
1 => Self::Engineering,
2 => Self::Programmer,
3 => Self::Convert,
4 => Self::Crc,
5 => Self::Settings,
_ => Self::Standard,
}
}
fn as_u8(self) -> u8 {
match self {
Self::Standard => 0,
Self::Engineering => 1,
Self::Programmer => 2,
Self::Convert => 3,
Self::Crc => 4,
Self::Settings => 5,
}
}
fn title(self) -> &'static str {
match self {
Self::Standard => "Standard",
Self::Engineering => "Engineering",
Self::Programmer => "Programmer",
Self::Convert => "Convert",
Self::Crc => "CRC",
Self::Settings => "Settings",
}
}
}
struct State {
@@ -37,19 +75,26 @@ struct State {
crc: CrcTool,
show_bits: bool,
win_profile: u32,
config: AppConfig,
}
impl Default for State {
fn default() -> Self {
let config = AppConfig::load();
let mut prog = Programmer::new();
if let Some(ws) = WordSize::from_label(&config.word_size) {
prog.set_word_size(ws);
}
Self {
mode: Mode::Standard,
mode: Mode::from_u8(config.last_mode),
std: Calculator::new(),
eng: Engineering::new(),
prog: Programmer::new(),
prog,
conv: Converter::new(),
crc: CrcTool::new(),
show_bits: true,
show_bits: config.show_bits,
win_profile: u32::MAX,
config,
}
}
}
@@ -85,8 +130,11 @@ fn run_app() -> Result<(), slint::PlatformError> {
ui.set_app_title(format!("Rcalc-{}", env!("APP_VERSION")).into());
let state = Rc::new(RefCell::new(State::default()));
refresh(&ui, &mut state.borrow_mut());
{
let mut s = state.borrow_mut();
apply_ui_style(&ui, &s.config);
refresh(&ui, &mut s);
}
let ui_weak = ui.as_weak();
let state_keys = state.clone();
@@ -94,6 +142,7 @@ fn run_app() -> Result<(), slint::PlatformError> {
let mut s = state_keys.borrow_mut();
handle_key(&mut s, id.as_str());
if let Some(ui) = ui_weak.upgrade() {
apply_ui_style(&ui, &s.config);
refresh(&ui, &mut s);
}
});
@@ -111,6 +160,44 @@ fn run_app() -> Result<(), slint::PlatformError> {
ui.run()
}
fn rgb(c: (u8, u8, u8)) -> slint::Color {
slint::Color::from_rgb_u8(c.0, c.1, c.2)
}
fn apply_ui_style(ui: &AppWindow, cfg: &AppConfig) {
let style = ui.global::<UiStyle>();
style.set_size(i32::from(cfg.button_size));
style.set_theme(cfg.theme.as_i32());
let c = theme_colors(cfg.theme);
style.set_bg(rgb(c.bg));
style.set_surface(rgb(c.surface));
style.set_btn(rgb(c.btn));
style.set_btn_hover(rgb(c.btn_hover));
style.set_digit(rgb(c.digit));
style.set_digit_hover(rgb(c.digit_hover));
style.set_accent(rgb(c.accent));
style.set_accent_hover(rgb(c.accent_hover));
style.set_text(rgb(c.text));
style.set_text_muted(rgb(c.text_muted));
style.set_text_dim(rgb(c.text_dim));
style.set_menu_active(rgb(c.menu_active));
style.set_eq_text(rgb(c.eq_text));
style.set_border(rgb(c.border));
ui.set_cfg_button_size(i32::from(cfg.button_size));
ui.set_cfg_theme(cfg.theme.as_i32());
}
fn persist_config(state: &mut State) {
if state.mode != Mode::Settings {
state.config.last_mode = state.mode.as_u8();
}
state.config.show_bits = state.show_bits;
state.config.word_size = state.prog.word_size().label().to_string();
state.config.button_size = AppConfig::clamp_button_size(state.config.button_size);
state.config.save();
}
fn install_panic_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
@@ -173,13 +260,7 @@ fn bit_row_model(row: &[(bool, u32)]) -> ModelRc<BitInfo> {
}
fn window_profile(state: &State) -> u32 {
let mode = match state.mode {
Mode::Standard => 0,
Mode::Engineering => 1,
Mode::Programmer => 2,
Mode::Convert => 3,
Mode::Crc => 4,
};
let mode = u32::from(state.mode.as_u8());
let bits = if state.show_bits {
state.prog.word_size().bits()
} else {
@@ -190,7 +271,9 @@ fn window_profile(state: &State) -> u32 {
ConvPanel::Ratio => 1,
ConvPanel::Ascii => 2,
};
mode * 1000 + bits * 10 + panel
let size = u32::from(state.config.button_size);
let theme = state.config.theme.as_i32() as u32;
mode * 100_000 + bits * 1000 + panel * 100 + size * 10 + theme
}
fn fit_window(ui: &AppWindow, state: &mut State) {
@@ -200,6 +283,12 @@ fn fit_window(ui: &AppWindow, state: &mut State) {
}
state.win_profile = profile;
let scale = match state.config.button_size {
1 => 0.92,
3 => 1.12,
_ => 1.0,
};
let (w, h) = match state.mode {
Mode::Standard => (360.0, 580.0),
Mode::Engineering => (420.0, 720.0),
@@ -218,9 +307,10 @@ fn fit_window(ui: &AppWindow, state: &mut State) {
}
Mode::Convert => (420.0, 820.0),
Mode::Crc => (460.0, 560.0),
Mode::Settings => (420.0, 420.0),
};
ui.window()
.set_size(slint::LogicalSize::new(w as f32, h as f32));
.set_size(slint::LogicalSize::new((w * scale) as f32, (h * scale) as f32));
}
fn apply_crc_results(ui: &AppWindow, state: &State) {
@@ -236,24 +326,11 @@ fn apply_crc_results(ui: &AppWindow, state: &State) {
}
fn refresh(ui: &AppWindow, state: &mut State) {
ui.set_mode(match state.mode {
Mode::Standard => 0,
Mode::Engineering => 1,
Mode::Programmer => 2,
Mode::Convert => 3,
Mode::Crc => 4,
});
ui.set_mode_title(
match state.mode {
Mode::Standard => "Standard",
Mode::Engineering => "Engineering",
Mode::Programmer => "Programmer",
Mode::Convert => "Convert",
Mode::Crc => "CRC",
}
.into(),
);
ui.set_mode(i32::from(state.mode.as_u8()));
ui.set_mode_title(state.mode.title().into());
ui.set_menu_open(false);
ui.set_cfg_button_size(i32::from(state.config.button_size));
ui.set_cfg_theme(state.config.theme.as_i32());
fit_window(ui, state);
@@ -335,29 +412,75 @@ fn refresh(ui: &AppWindow, state: &mut State) {
ui.set_crc_input(state.crc.input().into());
apply_crc_results(ui, state);
}
Mode::Settings => {}
}
}
fn set_mode(state: &mut State, mode: Mode) {
state.mode = mode;
persist_config(state);
}
fn handle_key(state: &mut State, id: &str) {
match id {
"mode:std" => {
state.mode = Mode::Standard;
set_mode(state, Mode::Standard);
return;
}
"mode:eng" => {
state.mode = Mode::Engineering;
set_mode(state, Mode::Engineering);
return;
}
"mode:prog" => {
state.mode = Mode::Programmer;
set_mode(state, Mode::Programmer);
return;
}
"mode:conv" => {
state.mode = Mode::Convert;
set_mode(state, Mode::Convert);
return;
}
"mode:crc" => {
state.mode = Mode::Crc;
set_mode(state, Mode::Crc);
return;
}
"mode:settings" => {
state.mode = Mode::Settings;
return;
}
"cfg:size:1" => {
state.config.button_size = 1;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
"cfg:size:2" => {
state.config.button_size = 2;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
"cfg:size:3" => {
state.config.button_size = 3;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
"cfg:theme:0" => {
state.config.theme = ThemeId::Dark;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
"cfg:theme:1" => {
state.config.theme = ThemeId::Light;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
"cfg:theme:2" => {
state.config.theme = ThemeId::Blue;
state.win_profile = u32::MAX;
persist_config(state);
return;
}
_ => {}
@@ -368,7 +491,7 @@ fn handle_key(state: &mut State, id: &str) {
Mode::Engineering => handle_engineering(&mut state.eng, id),
Mode::Programmer => handle_programmer(state, id),
Mode::Convert => handle_convert(&mut state.conv, id),
Mode::Crc => {}
Mode::Crc | Mode::Settings => {}
}
}
@@ -456,6 +579,13 @@ fn handle_standard(calc: &mut Calculator, id: &str) {
fn handle_programmer(state: &mut State, id: &str) {
if id == "bits" {
state.show_bits = !state.show_bits;
persist_config(state);
return;
}
if id == "word" {
state.prog.cycle_word_size();
persist_config(state);
return;
}
@@ -476,7 +606,6 @@ fn handle_programmer(state: &mut State, id: &str) {
"base:dec" => calc.set_base(Base::Dec),
"base:oct" => calc.set_base(Base::Oct),
"base:bin" => calc.set_base(Base::Bin),
"word" => calc.cycle_word_size(),
"+" => calc.set_op(ProgOp::Add),
"-" | "−" => calc.set_op(ProgOp::Sub),
"*" | "×" => calc.set_op(ProgOp::Mul),
+23 -1
View File
@@ -75,6 +75,18 @@ impl WordSize {
}
}
pub fn from_label(s: &str) -> Option<Self> {
match s.trim().to_ascii_uppercase().as_str() {
"QWORD" => Some(WordSize::Qword),
"DWORD" => Some(WordSize::Dword),
"DINT" => Some(WordSize::Dint),
"WORD" => Some(WordSize::Word),
"INT" => Some(WordSize::Int),
"BYTE" => Some(WordSize::Byte),
_ => None,
}
}
pub fn cycle(self) -> Self {
match self {
WordSize::Qword => WordSize::Dword,
@@ -310,7 +322,17 @@ impl Programmer {
}
self.typing = false;
}
self.word_size = self.word_size.cycle();
self.set_word_size(self.word_size.cycle());
}
pub fn set_word_size(&mut self, size: WordSize) {
if self.typing {
if let Ok(v) = parse_entry(&self.entry, self.base) {
self.value = v;
}
self.typing = false;
}
self.word_size = size;
self.value &= self.word_size.mask();
if let Some(p) = self.pending.as_mut() {
*p &= self.word_size.mask();