Compare commits

..

No commits in common. "master" and "v0.1" have entirely different histories.

12 changed files with 80 additions and 426 deletions

2
Cargo.lock generated
View file

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

View file

@ -1,6 +1,6 @@
[package]
name = "aliveline"
version = "0.2.0"
version = "0.1.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 = 3
opt-level = "s"

View file

@ -1,45 +1,38 @@
# Aliveline
# Slint Rust Template
A template for a Rust application that's using [Slint](https://slint.rs/) for the user interface.
## About
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`
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.
## Usage
Just run `aliveline` by any preferred way, for example:
```
$ ./aliveline
```
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
```
## 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.
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).
See the example [config.toml](http://2ndbeam.ru/git/2ndbeam/aliveline/src/branch/master/config.toml) for default values.
## Next Steps
## Contribution
You can contribute to Aliveline by creating issue on this repository, then we'll discuss it.
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.

View file

@ -1,53 +0,0 @@
# 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,138 +2,23 @@ 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 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())
}
}
pub log_path: PathBuf
}
impl Config {
pub fn new(conf_path: PathBuf) -> Self {
let conf_dir: PathBuf = conf_path.parent().unwrap().into();
Config {
conf_path: conf_dir,
..Default::default()
}
Config { conf_path: conf_dir, log_path: PathBuf::from("./logs") }
}
pub fn load(path: PathBuf) -> Self {
if let Ok(toml_string) = std::fs::read_to_string(path.clone()) {
let conf = toml::from_str::<RawConfig>(&toml_string);
if let Ok(raw_conf) = conf {
let mut conf: Config = raw_conf.into();
let conf = toml::from_str::<Self>(&toml_string);
if let Ok(mut conf) = conf {
conf.conf_path = path.parent().unwrap().into();
return conf;
}

View file

@ -1,5 +1,5 @@
use config::Config;
use std::{hash::{DefaultHasher, Hash, Hasher}, path::PathBuf};
use std::path::PathBuf;
pub mod config;
pub mod log;
@ -13,10 +13,3 @@ 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::{color_id_from_name, config::Config, load_config, log::{Event, Log}};
use aliveline::{config::Config, load_config, log::{Event, Log}};
use chrono::{Datelike, Timelike};
use slint::{Color, Model, ModelRc, SharedString, ToSharedString, VecModel, Weak};
use slint::{Model, ModelRc, SharedString, ToSharedString, VecModel};
use toml::value::{Date as TomlDate, Time};
slint::include_modules!();
@ -22,8 +22,7 @@ impl From<Event> for TimelineEvent {
start,
duration: end - start,
label: event.name.to_shared_string(),
finished: event.finished,
color_id: color_id_from_name(event.name)
finished: event.finished
}
}
}
@ -47,40 +46,6 @@ 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()?;
@ -96,13 +61,23 @@ 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();
load_log(ui_weak, log);
let ui_weak = ui.as_weak();
let config_arc = config.clone();
load_colors(ui_weak, config_arc);
{
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);
})()
}
ui.invoke_update_record_offset(offset as i32);
@ -168,9 +143,8 @@ fn main() -> Result<(), Box<dyn Error>> {
let event = TimelineEvent {
duration: 0,
finished: false,
label: event_name.clone(),
start: offset,
color_id: color_id_from_name(event_name.to_string())
label: event_name,
start: offset
};
{
@ -191,8 +165,7 @@ 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()
@ -203,16 +176,15 @@ fn main() -> Result<(), Box<dyn Error>> {
let new_event = TimelineEvent {
duration: offset - event.start,
finished: true,
label: event.label.clone(),
start: event.start,
color_id: color_id_from_name(event.label.to_string())
label: event.label,
start: event.start
};
{
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();
@ -230,47 +202,6 @@ 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,13 +2,11 @@ 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;
@ -32,6 +30,7 @@ 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,7 +2,6 @@ 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);
@ -13,29 +12,21 @@ 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: "";
property<bool> minimized: false;
property<int> combo-index: 0;
property<string> event-name <=> le.text;
tl := Timeline {
preferred-height: 100%;
updating: true;
clicked => {
minimized = !minimized;
}
}
if !minimized: GridLayout {
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";
@ -66,12 +57,11 @@ export component RecordWidget inherits VerticalBox {
}
ComboBox {
model: combo-spans;
current-index: combo-index;
current-index: 0;
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: tl.max-offset;
maximum: 24 * 3600;
value: offset;
row: 0;
colspan: 2;

View file

@ -1,43 +0,0 @@
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,11 +1,8 @@
import { Palette } from "theme.slint";
export struct TimelineEvent {
start: int,
duration: int,
finished: bool,
label: string,
color-id: int
label: string
}
global TimeString {
@ -27,35 +24,21 @@ 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;
}
}
ta := TouchArea {
preferred-width: 100%;
preferred-height: 100%;
}
background: gray;
border-width: 1px;
border-color: black;
Rectangle {
@ -66,64 +49,40 @@ export component Timeline inherits Rectangle {
height: parent.height / 2;
border-color: black;
border-width: 1px;
background: Palette.timeline;
background: purple;
}
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 ?
min(parent.width - self.x, real-width) :
(event.duration) / visible-time * parent.width + min(real-x, 0):
parent.width - self.x;
height: parent.height / 2;
visible: self.width > 0 && self.real-x < parent.width;
visible: self.real-x + self.width > 0 && self.real-x < parent.width;
border-color: black;
border-width: 1px;
background: Palette.event-colors[event.color-id];
background: red;
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