diff --git a/Cargo.lock b/Cargo.lock index 5aa4671..283cabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -147,7 +147,7 @@ dependencies = [ [[package]] name = "aliveline" -version = "0.1.0" +version = "0.2.0" dependencies = [ "chrono", "serde", diff --git a/Cargo.toml b/Cargo.toml index b7d15fc..4780d86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "aliveline" -version = "0.1.0" +version = "0.2.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html @@ -15,4 +15,4 @@ toml = "0.9.5" slint-build = "1.12.1" [profile.release] -opt-level = "s" +opt-level = 3 diff --git a/README.md b/README.md index 3a77de6..563edcf 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,45 @@ -# Slint Rust Template - -A template for a Rust application that's using [Slint](https://slint.rs/) for the user interface. +# Aliveline ## About -This template helps you get started developing a Rust application with Slint as toolkit -for the user interface. It demonstrates the integration between the `.slint` UI markup and -Rust code, how to react to callbacks, get and set properties, and use basic widgets. +Aliveline is a small app made with Rust + Slint to track daily activity on a timeline. +All activity is saved into TOML logs, which are human readable/editable. + +Aliveline currently supports Linux. + +## Features + +### Events +Events are main timeline building blocks. They have name, start and end. +Example of event in TOML format shown below: +```toml +[[events]] +name = "test" +start = 12:05:45 +end = 13:00:11 +finished = true +``` +_Note: if event is not finished yet, it may have_ `end = 00:00:00`. + +## Building +Requirements: +- Rust toolchain + +Instructions: +Run `cargo build --release` ## Usage -1. Install Rust by following its [getting-started guide](https://www.rust-lang.org/learn/get-started). - Once this is done, you should have the `rustc` compiler and the `cargo` build system installed in your `PATH`. -2. Download and extract the [ZIP archive of this repository](https://github.com/slint-ui/slint-rust-template/archive/refs/heads/main.zip). -3. Rename the extracted directory and change into it: - ``` - mv slint-rust-template-main my-project - cd my-project - ``` -4. Build with `cargo`: - ``` - cargo build - ``` -5. Run the application binary: - ``` - cargo run - ``` +Just run `aliveline` by any preferred way, for example: +``` +$ ./aliveline +``` -We recommend using an IDE for development, along with our [LSP-based IDE integration for `.slint` files](https://github.com/slint-ui/slint/blob/master/tools/lsp/README.md). You can also load this project directly in [Visual Studio Code](https://code.visualstudio.com) and install our [Slint extension](https://marketplace.visualstudio.com/items?itemName=Slint.slint). +## Configuration +Aliveline tries to find config at `$XDG_CONFIG_DIR/aliveline/config.toml`. +If config isn't found, or `$XDG_CONFIG_DIR` is not set, Aliveline uses default values. -## Next Steps +See the example [config.toml](http://2ndbeam.ru/git/2ndbeam/aliveline/src/branch/master/config.toml) for default values. -We hope that this template helps you get started, and that you enjoy exploring making user interfaces with Slint. To learn more -about the Slint APIs and the `.slint` markup language, check out our [online documentation](https://slint.dev/docs). - -Don't forget to edit this readme to replace it by yours, and edit the `name =` field in `Cargo.toml` to match the name of your -project. +## Contribution +You can contribute to Aliveline by creating issue on this repository, then we'll discuss it. diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..1bfb6b9 --- /dev/null +++ b/config.toml @@ -0,0 +1,53 @@ +# This is the default config for Aliveline. +# Note: All colors are of format 0xAARRGGBB + +# Path where logs are saved. May be relative to config dir or absolute. +log_path = "logs" + +# Colors used for events. For now Aliveline expects to have exactly 16 colors, but this is subject to change in future. +event_colors = [ + 0xff_97f9f9, + 0xff_a4def9, + 0xff_c1e0f7, + 0xff_cfbae1, + 0xff_c59fc9, + 0xff_4e3d42, + 0xff_c9d5b5, + 0xff_2d82b7, + 0xff_556f44, + 0xff_772e25, + 0xff_c44536, + 0xff_7c6a0a, + 0xff_babd8d, + 0xff_ffdac6, + 0xff_fa9500, + 0xff_eb6424 +] + +# Colors used for event colors. Aliveline expects it to have same size as events. +text_colors = [ + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_ffffff, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_ffffff, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_000000, + 0xff_000000 +] + +[colors] +# Color behind the timeline +background = 0xFF_808080 +# Color of the base timeline +timeline = 0xFF_a9a9a9 +# Color of background text (timestamps, event names, etc.) +background_text = 0xFF_000000 diff --git a/src/config.rs b/src/config.rs index 68afdf0..e1afe8d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -2,23 +2,138 @@ use std::path::PathBuf; use serde::Deserialize; #[derive(Deserialize)] +struct RawColors { + pub background: Option, + pub timeline: Option, + pub background_text: Option +} + +#[derive(Clone)] +pub struct Colors { + pub background: u32, + pub timeline: u32, + pub background_text: u32 +} + +impl Default for Colors { + fn default() -> Self { + Colors { + background: 0xff_808080, + timeline: 0xff_a9a9a9, + background_text: 0xff_000000 + } + } +} + +impl From for Colors { + fn from(value: RawColors) -> Self { + let default_colors: Colors = Default::default(); + Colors { + background: value.background.unwrap_or(default_colors.background), + timeline: value.timeline.unwrap_or(default_colors.timeline), + background_text: value.background_text.unwrap_or(default_colors.background_text), + } + } +} + +#[derive(Deserialize)] +struct RawConfig { + pub log_path: Option, + pub colors: Option, + pub event_colors: Option>, + pub text_colors: Option> +} + pub struct Config { /// directory, where config is located - #[serde(skip)] pub conf_path: PathBuf, - pub log_path: PathBuf + pub log_path: PathBuf, + pub colors: Colors, + pub event_colors: Vec, + pub text_colors: Vec +} + +impl Default for Config { + fn default() -> Self { + let conf_path = PathBuf::new(); + let colors: Colors = Default::default(); + let event_colors: Vec = vec![ + 0xff_97f9f9, + 0xff_a4def9, + 0xff_c1e0f7, + 0xff_cfbae1, + 0xff_c59fc9, + 0xff_4e3d42, + 0xff_c9d5b5, + 0xff_2d82b7, + 0xff_556f44, + 0xff_772e25, + 0xff_c44536, + 0xff_7c6a0a, + 0xff_babd8d, + 0xff_ffdac6, + 0xff_fa9500, + 0xff_eb6424 + ]; + let text_colors: Vec = vec![ + 0xff000000, + 0xff000000, + 0xff000000, + 0xff000000, + 0xff000000, + 0xffffffff, + 0xff000000, + 0xff000000, + 0xff000000, + 0xffffffff, + 0xff000000, + 0xff000000, + 0xff000000, + 0xff000000, + 0xff000000, + 0xff000000 + ]; + Config { + conf_path, + log_path: PathBuf::from("./logs"), + colors, + event_colors, + text_colors + } + } +} + +impl From for Config { + fn from(value: RawConfig) -> Self { + let default_config: Config = Default::default(); + let colors: Colors = match value.colors { + Some(raw_colors) => raw_colors.into(), + None => default_config.colors.clone() + }; + Config { + conf_path: default_config.conf_path, + log_path: value.log_path.unwrap_or(default_config.log_path), + colors, + event_colors: value.event_colors.unwrap_or(default_config.event_colors.clone()), + text_colors: value.text_colors.unwrap_or(default_config.text_colors.clone()) + } + } } impl Config { pub fn new(conf_path: PathBuf) -> Self { let conf_dir: PathBuf = conf_path.parent().unwrap().into(); - Config { conf_path: conf_dir, log_path: PathBuf::from("./logs") } + Config { + conf_path: conf_dir, + ..Default::default() + } } pub fn load(path: PathBuf) -> Self { if let Ok(toml_string) = std::fs::read_to_string(path.clone()) { - let conf = toml::from_str::(&toml_string); - if let Ok(mut conf) = conf { + let conf = toml::from_str::(&toml_string); + if let Ok(raw_conf) = conf { + let mut conf: Config = raw_conf.into(); conf.conf_path = path.parent().unwrap().into(); return conf; } diff --git a/src/lib.rs b/src/lib.rs index bdf3eb3..7eb4df8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,5 @@ use config::Config; -use std::path::PathBuf; +use std::{hash::{DefaultHasher, Hash, Hasher}, path::PathBuf}; pub mod config; pub mod log; @@ -13,3 +13,10 @@ pub fn load_config() -> Config { } Config::new(PathBuf::from("./config.toml")) } + +/// Get random-like color id in range 0..16 by computing string hash +pub fn color_id_from_name(name: String) -> i32 { + let mut s = DefaultHasher::new(); + name.hash(&mut s); + let hash = s.finish(); + (hash % 16) as i32 } diff --git a/src/main.rs b/src/main.rs index 19baaa8..9b1c5a0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -3,9 +3,9 @@ use std::{error::Error, rc::Rc, sync::{Arc, Mutex}}; -use aliveline::{config::Config, load_config, log::{Event, Log}}; +use aliveline::{color_id_from_name, config::Config, load_config, log::{Event, Log}}; use chrono::{Datelike, Timelike}; -use slint::{Model, ModelRc, SharedString, ToSharedString, VecModel}; +use slint::{Color, Model, ModelRc, SharedString, ToSharedString, VecModel, Weak}; use toml::value::{Date as TomlDate, Time}; slint::include_modules!(); @@ -22,7 +22,8 @@ impl From for TimelineEvent { start, duration: end - start, label: event.name.to_shared_string(), - finished: event.finished + finished: event.finished, + color_id: color_id_from_name(event.name) } } } @@ -46,6 +47,40 @@ impl From for Event { } } +fn load_log(ui_weak: Weak, log: Arc>) { + let ui = ui_weak.unwrap(); + let log_guard = log.lock().expect("Log shouldn't be used twice"); + let events: Vec = (*log_guard) + .events + .iter() + .map(|event| TimelineEvent::from((*event).clone())) + .collect(); + let in_progress = events.iter().any(|event| !event.finished); + let model: ModelRc = Rc::new(VecModel::from(events)).into(); + ui.set_record_events(model); + ui.set_in_progress(in_progress); +} + +fn load_colors(ui_weak: Weak, config: Arc) { + let ui = ui_weak.unwrap(); + let pal = ui.global::(); + pal.set_background(Color::from_argb_encoded(config.colors.background)); + pal.set_timeline(Color::from_argb_encoded(config.colors.timeline)); + pal.set_background_text(Color::from_argb_encoded(config.colors.background_text)); + + // This looks like war crime + let event_colors_rc: ModelRc = Rc::new(VecModel::from( + config.event_colors.iter() + .map(|value| Color::from_argb_encoded(*value)).collect::>() + )).into(); + pal.set_event_colors(event_colors_rc); + let event_text_rc: ModelRc = Rc::new(VecModel::from( + config.text_colors.iter() + .map(|value| Color::from_argb_encoded(*value)).collect::>() + )).into(); + pal.set_event_text(event_text_rc); +} + fn main() -> Result<(), Box> { let ui = AppWindow::new()?; @@ -61,23 +96,13 @@ fn main() -> Result<(), Box> { let config: Arc = Arc::new(load_config()); let writing_log: Arc> = Arc::new(Mutex::new(Log::load_from(&config, date))); - { - let ui_weak = ui.as_weak(); - let log = writing_log.clone(); - (move || { - let ui = ui_weak.unwrap(); - let log_guard = log.lock().expect("Log shouldn't be used twice"); - let events: Vec = (*log_guard) - .events - .iter() - .map(|event| TimelineEvent::from((*event).clone())) - .collect(); - let in_progress = events.iter().any(|event| !event.finished); - let model: ModelRc = Rc::new(VecModel::from(events)).into(); - ui.set_record_events(model); - ui.set_in_progress(in_progress); - })() - } + let ui_weak = ui.as_weak(); + let log = writing_log.clone(); + load_log(ui_weak, log); + + let ui_weak = ui.as_weak(); + let config_arc = config.clone(); + load_colors(ui_weak, config_arc); ui.invoke_update_record_offset(offset as i32); @@ -143,8 +168,9 @@ fn main() -> Result<(), Box> { let event = TimelineEvent { duration: 0, finished: false, - label: event_name, - start: offset + label: event_name.clone(), + start: offset, + color_id: color_id_from_name(event_name.to_string()) }; { @@ -165,7 +191,8 @@ fn main() -> Result<(), Box> { let ui = ui_weak.unwrap(); let events_rc = ui.get_record_events(); let events = events_rc.as_any() - .downcast_ref::>().unwrap(); + .downcast_ref::>() + .unwrap(); let offset = ui.get_record_offset(); let event_id = events.iter() @@ -176,15 +203,16 @@ fn main() -> Result<(), Box> { let new_event = TimelineEvent { duration: offset - event.start, finished: true, - label: event.label, - start: event.start + label: event.label.clone(), + start: event.start, + color_id: color_id_from_name(event.label.to_string()) }; { let mut log_guard = log.lock().expect("Log shouldn't be used twice"); - (*log_guard).events.push(Event::from(new_event.clone())); - let index = (*log_guard).events.iter().position(|data| !data.finished).unwrap(); - (*log_guard).events.swap_remove(index); + log_guard.events.push(Event::from(new_event.clone())); + let index = log_guard.events.iter().position(|data| !data.finished).unwrap(); + log_guard.events.swap_remove(index); } ui.invoke_save_log(); @@ -202,6 +230,47 @@ fn main() -> Result<(), Box> { } }); + ui.on_new_day_started({ + let ui_weak = ui.as_weak(); + let log = writing_log.clone(); + move || { + let ui = ui_weak.unwrap(); + + let new_event: Option = { + let log_guard = log.lock().expect("Log shouldn't be used twice"); + + let maybe_unfinished_event = log_guard.events.iter().find(|event| !event.finished); + match maybe_unfinished_event { + Some(unfinished_event) => Some(Event::new(unfinished_event.name.clone(), 0, 0, false)), + _ => None + } + }; + + ui.invoke_stop_event(); + + { + let mut log_guard = log.lock().expect("Log shouldn't be used twice"); + log_guard.events.clear(); + let now = chrono::Local::now(); + + let date = TomlDate { + year: now.year() as u16, + month: now.month() as u8, + day: now.day() as u8 + }; + + log_guard.date = date; + log_guard.events.clear(); + + if let Some(event) = new_event { + log_guard.events.push(event); + } + } + + load_log(ui.as_weak(), log.clone()); + ui.invoke_save_log(); + } + }); ui.run()?; diff --git a/ui/app-window.slint b/ui/app-window.slint index 89a10b6..7535149 100644 --- a/ui/app-window.slint +++ b/ui/app-window.slint @@ -2,11 +2,13 @@ import { TabWidget } from "std-widgets.slint"; import { RecordWidget } from "record.slint"; import { ReviewWidget } from "review.slint"; import { TimelineEvent } from "timeline.slint"; +export { Palette } from "theme.slint"; export component AppWindow inherits Window { callback start-new-event <=> record.start-new-event; callback stop-event <=> record.stop-event; callback chain-event <=> record.chain-event; + callback new-day-started <=> record.new-day-started; callback update-record-offset(int); callback save-log; @@ -30,7 +32,6 @@ export component AppWindow inherits Window { property<[string]> combo-spans: ["1 Hour", "4 Hours", "8 Hours", "24 Hours"]; title: "Aliveline"; - TabWidget { Tab { title: "Record"; diff --git a/ui/record.slint b/ui/record.slint index cf195af..4d7090c 100644 --- a/ui/record.slint +++ b/ui/record.slint @@ -2,6 +2,7 @@ import { VerticalBox, LineEdit, Button, ComboBox } from "std-widgets.slint"; import { Timeline } from "timeline.slint"; export component RecordWidget inherits VerticalBox { + callback new-day-started <=> tl.new-day-started; callback update-visible-time(string); callback start-new-event(string); callback chain-event(string); @@ -12,21 +13,29 @@ export component RecordWidget inherits VerticalBox { in-out property events <=> tl.events; in property<[string]> combo-spans: []; in-out property in-progress: false; - property event-name <=> le.text; - + property event-name: ""; + property minimized: false; + property combo-index: 0; tl := Timeline { + preferred-height: 100%; updating: true; + clicked => { + minimized = !minimized; + } } - GridLayout { + if !minimized: GridLayout { spacing-vertical: 8px; spacing-horizontal: 16px; le := LineEdit { placeholder-text: "Event name"; - text: "Event name"; + text: event-name; font-size: 24px; horizontal-alignment: center; colspan: 2; row: 0; + edited(text) => { + event-name = text; + } } Button { text: in-progress ? "Stop" : "Start"; @@ -57,11 +66,12 @@ export component RecordWidget inherits VerticalBox { } ComboBox { model: combo-spans; - current-index: 0; + current-index: combo-index; row: 2; col: 1; selected(current-value) => { root.update-visible-time(current-value); + combo-index = self.current-index; } } } diff --git a/ui/review.slint b/ui/review.slint index 9727c24..14727ce 100644 --- a/ui/review.slint +++ b/ui/review.slint @@ -22,7 +22,7 @@ export component ReviewWidget inherits VerticalBox { spacing-horizontal: 16px; Slider { minimum: visible-time; - maximum: 24 * 3600; + maximum: tl.max-offset; value: offset; row: 0; colspan: 2; diff --git a/ui/theme.slint b/ui/theme.slint new file mode 100644 index 0000000..30b211a --- /dev/null +++ b/ui/theme.slint @@ -0,0 +1,43 @@ +export global Palette { + in-out property background: gray; + in-out property timeline: darkgray; + in-out property background-text: black; + // Note: these colors were almost randomly picked + in-out property<[color]> event-colors: [ + #97f9f9, + #a4def9, + #c1e0f7, + #cfbae1, + #c59fc9, + #4e3d42, + #c9d5b5, + #2d82b7, + #556f44, + #772e25, + #c44536, + #7c6a0a, + #babd8d, + #ffdac6, + #fa9500, + #eb6424 + ]; + + in-out property <[color]> event-text: [ + #000000, + #000000, + #000000, + #000000, + #000000, + #ffffff, + #000000, + #000000, + #000000, + #ffffff, + #000000, + #000000, + #000000, + #000000, + #000000, + #000000 + ]; +} diff --git a/ui/timeline.slint b/ui/timeline.slint index c6069a0..2e9db7f 100644 --- a/ui/timeline.slint +++ b/ui/timeline.slint @@ -1,8 +1,11 @@ +import { Palette } from "theme.slint"; + export struct TimelineEvent { start: int, duration: int, finished: bool, - label: string + label: string, + color-id: int } global TimeString { @@ -24,21 +27,35 @@ global TimeString { } export component Timeline inherits Rectangle { + callback new-day-started; + callback clicked <=> ta.clicked; + background: Palette.background; + in-out property updating: true; in-out property<[TimelineEvent]> events: []; in-out property visible-time: 3600; property visible-offset: max(offset, visible-time); in-out property offset: 0; + out property max-offset: 24 * 3600 - 1; timer := Timer { interval: 1s; running: updating; triggered => { + if (offset >= max-offset) { + root.new-day-started(); + offset = 0; + return; + } offset += 1; } } - background: gray; + ta := TouchArea { + preferred-width: 100%; + preferred-height: 100%; + } + border-width: 1px; border-color: black; Rectangle { @@ -49,40 +66,64 @@ export component Timeline inherits Rectangle { height: parent.height / 2; border-color: black; border-width: 1px; - background: purple; + background: Palette.timeline; } Text { x: 0; y: parent.height - self.height; text: TimeString.from(visible-offset - visible-time); + color: Palette.background-text; } Text { x: parent.width - self.width; y: parent.height - self.height; text: TimeString.from(visible-offset); + color: Palette.background-text; } for event in events: timeline-event := Rectangle { property real-x: ((visible-time - (visible-offset - event.start)) / visible-time) * parent.width; + property real-width: event.duration / visible-time * parent.width + min(real-x, 0); x: max(real-x, 0); y: parent.height / 4; z: 1; width: event.finished ? - (event.duration) / visible-time * parent.width + min(real-x, 0): + min(parent.width - self.x, real-width) : parent.width - self.x; height: parent.height / 2; - visible: self.real-x + self.width > 0 && self.real-x < parent.width; + visible: self.width > 0 && self.real-x < parent.width; border-color: black; border-width: 1px; - background: red; - + background: Palette.event-colors[event.color-id]; + Text { x: 0; y: -self.height; text: event.label; visible: timeline-event.visible; + color: Palette.background-text; + } + start-txt := Text { + x: 0; + y: root.height - self.height - timeline-event.height; + text: timeline-event.x == timeline-event.real-x ? + TimeString.from(event.start) : + TimeString.from(visible-offset - visible-time); + visible: timeline-event.visible && + (self.width * 2 < timeline-event.width || + (!end-txt.visible && self.width < timeline-event.width)); + color: Palette.event-text[event.color-id]; + } + end-txt := Text { + x: timeline-event.width - self.width; + y: root.height - self.height - timeline-event.height; + text: timeline-event.x + timeline-event.real-width <= root.width ? + TimeString.from(event.start + event.duration) : + TimeString.from(visible-offset); + visible: timeline-event.visible && timeline-event.width - self.width * 2 > 0; + color: Palette.event-text[event.color-id]; } } @children