Compare commits

..

12 commits

Author SHA1 Message Date
dc10194971 Bump version to 0.2.0 2025-09-19 00:33:23 +03:00
190ed0639a Added config example 2025-09-19 00:30:55 +03:00
ba976d9e12 Made every thing in Config optional internally 2025-09-19 00:10:20 +03:00
1b6f8ef282 Configurable colors 2025-09-18 17:33:34 +03:00
ca3c171698 Improved color picker algorithm 2025-09-18 16:35:30 +03:00
218ee49a8b Pseudo pseudorandom color picker 2025-09-18 16:30:37 +03:00
8df3893baa Minimizable record options 2025-09-15 17:26:04 +03:00
31281295bb Added event start/end timestamps 2025-09-15 16:54:05 +03:00
4650fde884 Updated Readme again again... 2025-09-12 15:22:43 +03:00
0c2a3d7e95 Updated Readme again 2025-09-12 15:21:41 +03:00
6a1f371f1d Updated Readme 2025-09-12 15:18:36 +03:00
b5e9a4115a Several bugfixes
- Fixed event invisibility when offset is after event's half width
- Fixed event being shown outside timeline component
- When timer reaches 24:00:00, new day is automatically started
2025-09-12 13:59:12 +03:00
12 changed files with 426 additions and 80 deletions

2
Cargo.lock generated
View file

@ -147,7 +147,7 @@ dependencies = [
[[package]]
name = "aliveline"
version = "0.1.0"
version = "0.2.0"
dependencies = [
"chrono",
"serde",

View file

@ -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

View file

@ -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.

53
config.toml Normal file
View file

@ -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

View file

@ -2,23 +2,138 @@ use std::path::PathBuf;
use serde::Deserialize;
#[derive(Deserialize)]
struct RawColors {
pub background: Option<u32>,
pub timeline: Option<u32>,
pub background_text: Option<u32>
}
#[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<RawColors> 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<PathBuf>,
pub colors: Option<RawColors>,
pub event_colors: Option<Vec<u32>>,
pub text_colors: Option<Vec<u32>>
}
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<u32>,
pub text_colors: Vec<u32>
}
impl Default for Config {
fn default() -> Self {
let conf_path = PathBuf::new();
let colors: Colors = Default::default();
let event_colors: Vec<u32> = 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<u32> = 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<RawConfig> 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::<Self>(&toml_string);
if let Ok(mut conf) = conf {
let conf = toml::from_str::<RawConfig>(&toml_string);
if let Ok(raw_conf) = conf {
let mut conf: Config = raw_conf.into();
conf.conf_path = path.parent().unwrap().into();
return conf;
}

View file

@ -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 }

View file

@ -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<Event> 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<TimelineEvent> for Event {
}
}
fn load_log(ui_weak: Weak<AppWindow>, log: Arc<Mutex<Log>>) {
let ui = ui_weak.unwrap();
let log_guard = log.lock().expect("Log shouldn't be used twice");
let events: Vec<TimelineEvent> = (*log_guard)
.events
.iter()
.map(|event| TimelineEvent::from((*event).clone()))
.collect();
let in_progress = events.iter().any(|event| !event.finished);
let model: ModelRc<TimelineEvent> = Rc::new(VecModel::from(events)).into();
ui.set_record_events(model);
ui.set_in_progress(in_progress);
}
fn load_colors(ui_weak: Weak<AppWindow>, config: Arc<Config>) {
let ui = ui_weak.unwrap();
let pal = ui.global::<Palette>();
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<Color> = Rc::new(VecModel::from(
config.event_colors.iter()
.map(|value| Color::from_argb_encoded(*value)).collect::<Vec<Color>>()
)).into();
pal.set_event_colors(event_colors_rc);
let event_text_rc: ModelRc<Color> = Rc::new(VecModel::from(
config.text_colors.iter()
.map(|value| Color::from_argb_encoded(*value)).collect::<Vec<Color>>()
)).into();
pal.set_event_text(event_text_rc);
}
fn main() -> Result<(), Box<dyn Error>> {
let ui = AppWindow::new()?;
@ -61,23 +96,13 @@ fn main() -> Result<(), Box<dyn Error>> {
let config: Arc<Config> = Arc::new(load_config());
let writing_log: Arc<Mutex<Log>> = 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<TimelineEvent> = (*log_guard)
.events
.iter()
.map(|event| TimelineEvent::from((*event).clone()))
.collect();
let in_progress = events.iter().any(|event| !event.finished);
let model: ModelRc<TimelineEvent> = 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<dyn Error>> {
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<dyn Error>> {
let ui = ui_weak.unwrap();
let events_rc = ui.get_record_events();
let events = events_rc.as_any()
.downcast_ref::<VecModel<TimelineEvent>>().unwrap();
.downcast_ref::<VecModel<TimelineEvent>>()
.unwrap();
let offset = ui.get_record_offset();
let event_id = events.iter()
@ -176,15 +203,16 @@ fn main() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
}
});
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<Event> = {
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()?;

View file

@ -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";

View file

@ -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<bool> in-progress: false;
property<string> event-name <=> le.text;
property<string> event-name: "";
property<bool> minimized: false;
property<int> 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;
}
}
}

View file

@ -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;

43
ui/theme.slint Normal file
View file

@ -0,0 +1,43 @@
export global Palette {
in-out property<color> background: gray;
in-out property<color> timeline: darkgray;
in-out property<color> 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
];
}

View file

@ -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<bool> updating: true;
in-out property<[TimelineEvent]> events: [];
in-out property<int> visible-time: 3600;
property<int> visible-offset: max(offset, visible-time);
in-out property<int> offset: 0;
out property<int> 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<length> real-x: ((visible-time - (visible-offset - event.start)) / visible-time) * parent.width;
property<length> 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