Compare commits

..

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

22 changed files with 1500 additions and 2148 deletions

2763
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,20 +1,18 @@
[package] [package]
name = "aliveline" name = "aliveline"
version = "0.3.0" version = "0.2.1"
edition = "2021" edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
chrono = "0.4.42" chrono = "0.4.42"
serde = "1.0.219" serde = "1.0.219"
slint = "1.15.1" slint = "1.12.1"
toml = "1.1.2" toml = "0.9.5"
[build-dependencies] [build-dependencies]
slint-build = "1.15.1" slint-build = "1.12.1"
[profile.release] [profile.release]
opt-level = 3 opt-level = 3
[features]
light = []
dark = []

View file

@ -34,22 +34,12 @@ finished = true
_Note: if event is not finished yet, it may have_ `end = start`. _Note: if event is not finished yet, it may have_ `end = start`.
## Building ## Building
Requirements:
### Requirements
- Rust toolchain - Rust toolchain
- Slint dependencies (see [Platforms](https://docs.slint.dev/latest/docs/slint/guide/platforms/desktop/) and [Backends & Renderers](https://docs.slint.dev/latest/docs/slint/guide/backends-and-renderers/backends_and_renderers/)) - Slint dependencies (see [Platforms](https://docs.slint.dev/latest/docs/slint/guide/platforms/desktop/) and [Backends & Renderers](https://docs.slint.dev/latest/docs/slint/guide/backends-and-renderers/backends_and_renderers/))
### Feature flags Instructions:
By default Aliveline compiles with theme autodetection, provided by Slint, which sometimes does not work on Linux. Just run `cargo build --release` and the resulting binary can be located at `target/release/aliveline[.exe]` if compilation succeeds.
You can use these flags to compile Aliveline with selected theme:
- `light`
- `dark`
### Instructions
Run `cargo build --release`
Pass features in build command with `-F foo` or `--feature foo`
Resulting binary will be located at `target/release/aliveline[.exe]`
If compilation fails, double check that you have all required dependencies. If it still fails, file an issue on [Codeberg](https://codeberg.org/2ndbeam/aliveline/issues), including logs and system info. If compilation fails, double check that you have all required dependencies. If it still fails, file an issue on [Codeberg](https://codeberg.org/2ndbeam/aliveline/issues), including logs and system info.
## Usage ## Usage

5
TODO Normal file
View file

@ -0,0 +1,5 @@
feat:
implement instants
command line interface
proper config path handling
more coloring options

View file

@ -1,17 +1,3 @@
fn main() { fn main() {
let mut style = String::from("cosmic"); slint_build::compile("ui/app-window.slint").expect("Slint build failed");
#[cfg(all(feature = "dark", feature = "light"))]
compile_error!("Features \"dark\" and \"light\" are mutually exclusive");
if cfg!(feature = "dark") {
style.push_str("-dark");
} else if cfg!(feature = "light") {
style.push_str("-light");
}
let config = slint_build::CompilerConfiguration::new()
.with_style(style);
slint_build::compile_with_config("ui/app-window.slint", config).expect("Slint build failed");
} }

View file

@ -1,50 +1,53 @@
# Default Aliveline config # This is the default config for Aliveline.
# Note: All colors are of format 0xAARRGGBB
# Paths may be relative to config directory or absolute # Path where logs are saved. May be relative to config dir or absolute.
[paths] log_path = "logs"
logs = "logs"
# Colors may be defined either as alias (string) or as 0xAARRGGBB (integer) # 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
]
# Aliases must be declared here, otherwise fallback color is used # Colors used for event colors. Aliveline expects it to have same size as events.
[colors.aliases] text_colors = [
background = 0xFF_808080 0xff_000000,
timeline = 0xFF_A9A9A9 0xff_000000,
black = 0xFF_000000 0xff_000000,
white = 0xFF_FFFFFF 0xff_000000,
"very aggressive pink color to use with fallback so you definitely notice it" = 0xFF_FF00E7 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] [colors]
# Timeline background # Color behind the timeline
background = "background" background = 0xFF_808080
# Timeline foreground # Color of the base timeline
timeline = "timeline" timeline = 0xFF_a9a9a9
# Background text (timestamps, event names, etc.) # Color of background text (timestamps, event names, etc.)
text = "black" background_text = 0xFF_000000
# Used when alias was not found
fallback = "very aggressive pink color to use with fallback so you definitely notice it"
# Event colors are chosen pseudorandomly from this array, respecting each color's priority
# Event color consists of:
# - Background color (default: black)
# - Text color (default: same as colors.text)
# - Priority (default: 1)
# Full example: { background = "color", text = "color", priority = 1337 }
events = [
{ background = 0xff_97f9f9 },
{ background = 0xff_a4def9 },
{ background = 0xff_c1e0f7 },
{ background = 0xff_cfbae1 },
{ background = 0xff_c59fc9 },
{ background = 0xff_4e3d42, text = "white" },
{ background = 0xff_c9d5b5 },
{ background = 0xff_2d82b7 },
{ background = 0xff_556f44 },
{ background = 0xff_772e25, text = "white" },
{ background = 0xff_c44536 },
{ background = 0xff_7c6a0a },
{ background = 0xff_babd8d },
{ background = 0xff_ffdac6 },
{ background = 0xff_fa9500 },
{ background = 0xff_eb6424 },
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Before After
Before After

104
src/config.rs Normal file
View file

@ -0,0 +1,104 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Debug)]
#[serde(default)]
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,
}
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
pub struct Config {
/// directory, where config is located
#[serde(skip)]
pub conf_path: PathBuf,
pub log_path: PathBuf,
#[serde(default)]
pub colors: Colors,
#[serde(default)]
pub event_colors: Vec<u32>,
#[serde(default)]
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 Config {
pub fn new(conf_path: PathBuf) -> Self {
let conf_dir: PathBuf = conf_path.parent().unwrap().into();
Config {
conf_path: conf_dir,
..Default::default()
}
}
pub fn load(path: PathBuf) -> Option<Self> {
if let Ok(toml_string) = std::fs::read_to_string(path.clone()) {
if let Ok(mut conf) = toml::from_str::<Config>(&toml_string) {
conf.conf_path = path.parent().unwrap().into();
return Some(conf);
}
}
None
}
}

View file

@ -1,99 +0,0 @@
use std::{collections::HashMap, hash::{DefaultHasher, Hash, Hasher}};
use serde::{Serialize, Deserialize};
use slint::Color;
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(untagged)]
pub enum ConfigColor {
Raw(u32),
Alias(String),
}
impl Default for ConfigColor {
fn default() -> Self {
Self::Raw(0xFF000000)
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(default)]
pub struct EventColor {
pub background: ConfigColor,
pub text: Option<ConfigColor>,
pub priority: u64,
}
impl Default for EventColor {
fn default() -> Self {
Self {
background: ConfigColor::default(),
text: None,
priority: 1,
}
}
}
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(default)]
pub struct Colors {
pub aliases: HashMap<String, u32>,
pub background: ConfigColor,
pub timeline: ConfigColor,
pub text: ConfigColor,
pub fallback: ConfigColor,
pub events: Vec<EventColor>,
}
impl Default for Colors {
fn default() -> Self {
super::default::default_config().colors.into()
}
}
#[inline(always)]
fn argb(value: u32) -> Color {
Color::from_argb_encoded(value)
}
impl Colors {
#[inline(always)]
pub fn fallback_color(&self) -> Color {
self.try_get_color(&self.fallback).unwrap_or_default()
}
pub fn try_get_color(&self, color: &ConfigColor) -> Option<Color> {
match color {
ConfigColor::Raw(color) => Some(argb(*color)),
ConfigColor::Alias(alias) => self.aliases.get(alias).map(|c| argb(*c)),
}
}
pub fn get_color(&self, color: &ConfigColor) -> Color {
self.try_get_color(color).unwrap_or_else(|| self.fallback_color())
}
pub fn event_color_for(&self, text: &str) -> &EventColor {
let priority_sum: u64 = self.events.iter().map(|e| e.priority).sum();
let mut s = DefaultHasher::new();
text.hash(&mut s);
let hash = s.finish();
let mut chosen = hash % priority_sum;
for (id, color) in self.events.iter().enumerate() {
match chosen.checked_sub(color.priority) {
Some(new_count) => {
chosen = new_count;
},
None => {
return &self.events[id];
},
}
}
unreachable!()
}
}

View file

@ -1,53 +0,0 @@
use std::collections::HashMap;
use crate::config::color::{ConfigColor, EventColor};
use super::*;
const DEFAULT_CFG: &'static str = include_str!("../../config.toml");
#[derive(Deserialize)]
pub(super) struct DefaultConfig {
pub(super) colors: DefaultColors,
pub(super) paths: DefaultPaths,
}
#[derive(Deserialize)]
pub struct DefaultColors {
pub aliases: HashMap<String, u32>,
pub background: ConfigColor,
pub timeline: ConfigColor,
pub text: ConfigColor,
pub fallback: ConfigColor,
pub events: Vec<EventColor>,
}
impl From<DefaultColors> for Colors {
fn from(value: DefaultColors) -> Self {
Self {
aliases: value.aliases,
background: value.background,
timeline: value.timeline,
text: value.text,
fallback: value.fallback,
events: value.events
}
}
}
#[derive(Deserialize)]
pub struct DefaultPaths {
pub logs: PathBuf,
}
impl From<DefaultPaths> for Paths {
fn from(value: DefaultPaths) -> Self {
Self { logs: value.logs }
}
}
pub(super) fn default_config() -> DefaultConfig {
toml::de::from_str(DEFAULT_CFG).unwrap()
}

View file

@ -1,57 +0,0 @@
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use color::Colors;
pub mod color;
mod default;
#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Debug)]
#[serde(default)]
pub struct Paths {
pub logs: PathBuf,
}
impl Default for Paths {
fn default() -> Self {
default::default_config().paths.into()
}
}
#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq, Debug)]
#[serde(default)]
pub struct Config {
/// directory, where config is located
#[serde(skip)]
pub conf_path: PathBuf,
pub colors: Colors,
pub paths: Paths,
}
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()
}
}
fn replace_missing_fields(&mut self) {
let default = Config::default();
if self.colors.events.len() == 0 {
self.colors.events = default.colors.events;
}
}
pub fn load(path: PathBuf) -> Option<Self> {
if let Ok(toml_string) = std::fs::read_to_string(path.clone()) {
if let Ok(mut conf) = toml::from_str::<Config>(&toml_string) {
conf.replace_missing_fields();
conf.conf_path = path.parent().unwrap().into();
return Some(conf);
}
}
None
}
}

View file

@ -1,5 +1,5 @@
use config::Config; use config::Config;
use std::path::PathBuf; use std::{hash::{DefaultHasher, Hash, Hasher}, path::PathBuf};
pub mod config; pub mod config;
pub mod log; pub mod log;
@ -45,3 +45,9 @@ pub fn load_config() -> Config {
println!("Using config path ./config.toml"); println!("Using config path ./config.toml");
Config::new(PathBuf::from("./config.toml")) 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

@ -46,12 +46,12 @@ impl Log {
} }
fn get_log_dir(config: &Config) -> PathBuf { fn get_log_dir(config: &Config) -> PathBuf {
if config.paths.logs.is_relative() { if config.log_path.is_relative() {
let mut path = config.conf_path.clone(); let mut path = config.conf_path.clone();
path.push(&config.paths.logs); path.push(&config.log_path);
return path; return path;
} else { } else {
return config.paths.logs.clone(); return config.log_path.clone();
} }
} }
@ -77,14 +77,14 @@ impl Event {
let start = Time { let start = Time {
hour: (start / 3600) as u8, hour: (start / 3600) as u8,
minute: ((start / 3600) / 60) as u8, minute: ((start / 3600) / 60) as u8,
second: Some((start % 60) as u8), second: (start % 60) as u8,
nanosecond: None, nanosecond: 0
}; };
let end = Time { let end = Time {
hour: (end / 3600) as u8, hour: (end / 3600) as u8,
minute: ((end % 3600) / 60) as u8, minute: ((end % 3600) / 60) as u8,
second: Some((end % 60) as u8), second: (end % 60) as u8,
nanosecond: None, nanosecond: 0
}; };
Event { name, start, end, finished } Event { name, start, end, finished }
} }

View file

@ -3,40 +3,30 @@
use std::{error::Error, rc::Rc, sync::{Arc, Mutex}}; use std::{error::Error, rc::Rc, sync::{Arc, Mutex}};
use aliveline::{config::{Config, color::Colors}, load_config, log::{Event, Log}}; use aliveline::{color_id_from_name, config::Config, load_config, log::{Event, Log}};
use chrono::{Datelike, Timelike}; use chrono::{Datelike, Timelike};
use slint::{Color, Model, ModelRc, SharedString, ToSharedString, VecModel, Weak}; use slint::{Color, Model, ModelRc, SharedString, ToSharedString, VecModel, Weak};
use toml::value::{Date as TomlDate, Time}; use toml::value::{Date as TomlDate, Time};
const DEFAULT_WINDOW_WIDTH: f32 = 800.;
const DEFAULT_WINDOW_HEIGHT: f32 = 600.;
slint::include_modules!(); slint::include_modules!();
impl TimelineEvent { impl From<Event> for TimelineEvent {
pub fn colors(colors: &Colors, text: &str) -> (Color, Color) { fn from(event: Event) -> Self {
let event_colors = colors.event_color_for(text);
let background_color = colors.get_color(&event_colors.background);
let text_color = match &event_colors.text {
Some(color) => colors.get_color(color),
None => colors.get_color(&colors.text),
};
(background_color, text_color)
}
pub fn from_event(event: Event, colors: &Colors) -> Self {
let start = (event.start.hour as i32) * 3600 let start = (event.start.hour as i32) * 3600
+ (event.start.minute as i32) * 60 + (event.start.minute as i32) * 60
+ (event.start.second.unwrap() as i32); + (event.start.second as i32);
let end = (event.end.hour as i32) * 3600 let end = (event.end.hour as i32) * 3600
+ (event.end.minute as i32) * 60 + (event.end.minute as i32) * 60
+ (event.end.second.unwrap() as i32); + (event.end.second as i32);
let (background_color, text_color) = TimelineEvent::colors(colors, event.name.as_str());
TimelineEvent { TimelineEvent {
start, start,
duration: end - start, duration: end - start,
label: event.name.to_shared_string(), label: event.name.to_shared_string(),
finished: event.finished, finished: event.finished,
background_color, color_id: color_id_from_name(event.name)
text_color,
} }
} }
} }
@ -46,33 +36,31 @@ impl From<TimelineEvent> for Event {
let start = Time { let start = Time {
hour: (event.start / 3600) as u8, hour: (event.start / 3600) as u8,
minute: ((event.start % 3600) / 60) as u8, minute: ((event.start % 3600) / 60) as u8,
second: Some((event.start % 60) as u8), second: (event.start % 60) as u8,
nanosecond: None, nanosecond: 0
}; };
let endsecs = event.start + event.duration; let endsecs = event.start + event.duration;
let end = Time { let end = Time {
hour: (endsecs / 3600) as u8, hour: (endsecs / 3600) as u8,
minute: ((endsecs % 3600) / 60) as u8, minute: ((endsecs % 3600) / 60) as u8,
second: Some((endsecs % 60) as u8), second: (endsecs % 60) as u8,
nanosecond: None, nanosecond: 0
}; };
Event { start, end, name: event.label.to_string(), finished: event.finished } Event { start, end, name: event.label.to_string(), finished: event.finished }
} }
} }
fn load_log(ui_weak: Weak<AppWindow>, log: Arc<Mutex<Log>>, config: Arc<Config>) { fn load_log(ui_weak: Weak<AppWindow>, log: Arc<Mutex<Log>>) {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let log_guard = log.lock().expect("Log shouldn't be used twice"); let log_guard = log.lock().expect("Log shouldn't be used twice");
let events: Vec<TimelineEvent> = (*log_guard) let events: Vec<TimelineEvent> = (*log_guard)
.events .events
.iter() .iter()
.map(|event| TimelineEvent::from_event(event.clone(), &config.colors)) .map(|event| TimelineEvent::from((*event).clone()))
.collect(); .collect();
let in_progress = events.iter().any(|event| !event.finished); let in_progress = events.iter().any(|event| !event.finished);
let model: ModelRc<TimelineEvent> = Rc::new(VecModel::from(events)).into(); let model: ModelRc<TimelineEvent> = Rc::new(VecModel::from(events)).into();
let mut state = ui.get_record_state(); ui.set_record_events(model);
state.events = model;
ui.set_record_state(state);
ui.set_in_progress(in_progress); ui.set_in_progress(in_progress);
ui.invoke_get_previous_event(); ui.invoke_get_previous_event();
} }
@ -80,9 +68,21 @@ fn load_log(ui_weak: Weak<AppWindow>, log: Arc<Mutex<Log>>, config: Arc<Config>)
fn load_colors(ui_weak: Weak<AppWindow>, config: Arc<Config>) { fn load_colors(ui_weak: Weak<AppWindow>, config: Arc<Config>) {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let pal = ui.global::<Palette>(); let pal = ui.global::<Palette>();
pal.set_background(config.colors.get_color(&config.colors.background)); pal.set_background(Color::from_argb_encoded(config.colors.background));
pal.set_timeline(config.colors.get_color(&config.colors.timeline)); pal.set_timeline(Color::from_argb_encoded(config.colors.timeline));
pal.set_background_text(config.colors.get_color(&config.colors.text)); 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>> { fn main() -> Result<(), Box<dyn Error>> {
@ -102,14 +102,14 @@ fn main() -> Result<(), Box<dyn Error>> {
let ui_weak = ui.as_weak(); let ui_weak = ui.as_weak();
let log = writing_log.clone(); let log = writing_log.clone();
let config_arc = config.clone(); load_log(ui_weak, log);
load_log(ui_weak, log, config_arc);
let ui_weak = ui.as_weak(); let ui_weak = ui.as_weak();
let config_arc = config.clone(); let config_arc = config.clone();
load_colors(ui_weak, config_arc); load_colors(ui_weak, config_arc);
ui.invoke_update_record_offset(offset as i32); ui.invoke_update_record_offset(offset as i32);
ui.invoke_update_window_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT);
ui.on_fetch_log({ ui.on_fetch_log({
let config = config.clone(); let config = config.clone();
@ -124,12 +124,10 @@ fn main() -> Result<(), Box<dyn Error>> {
let events: Vec<TimelineEvent> = Log::load_from(&config, date) let events: Vec<TimelineEvent> = Log::load_from(&config, date)
.events .events
.iter() .iter()
.map(|event| TimelineEvent::from_event(event.clone(), &config.colors)) .map(|event| TimelineEvent::from((*event).clone()))
.collect(); .collect();
let model: ModelRc<TimelineEvent> = Rc::new(VecModel::from(events)).into(); let model: ModelRc<TimelineEvent> = Rc::new(VecModel::from(events)).into();
let mut state = ui.get_review_state(); ui.set_review_events(model);
state.events = model;
ui.set_review_state(state);
} }
}); });
@ -153,38 +151,31 @@ fn main() -> Result<(), Box<dyn Error>> {
.map(|h| h.parse::<i32>().unwrap()) .map(|h| h.parse::<i32>().unwrap())
.unwrap(); .unwrap();
if is_record { if is_record {
let mut state = ui.get_record_state(); ui.set_record_visible_time(hours * 3600);
state.visible_time = hours * 3600;
ui.set_record_state(state);
} else { } else {
let mut state = ui.get_review_state(); ui.set_review_visible_time(hours * 3600);
state.visible_time = hours * 3600; }
ui.set_review_state(state);
};
} }
}); });
ui.on_start_new_event({ ui.on_start_new_event({
let ui_weak = ui.as_weak(); let ui_weak = ui.as_weak();
let log = writing_log.clone(); let log = writing_log.clone();
let config = config.clone();
move |event_name: SharedString| { move |event_name: SharedString| {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let state = ui.get_record_state(); let events_rc = ui.get_record_events();
let events = state.events.as_any() let events = events_rc.as_any()
.downcast_ref::<VecModel<TimelineEvent>>() .downcast_ref::<VecModel<TimelineEvent>>()
.unwrap(); .unwrap();
let offset = ui.get_record_offset();
let (background_color, text_color) = TimelineEvent::colors(&config.colors, event_name.as_str());
let event = TimelineEvent { let event = TimelineEvent {
duration: 0, duration: 0,
finished: false, finished: false,
label: event_name.clone(), label: event_name.clone(),
start: state.offset, start: offset,
background_color, color_id: color_id_from_name(event_name.to_string())
text_color,
}; };
{ {
@ -201,29 +192,25 @@ fn main() -> Result<(), Box<dyn Error>> {
ui.on_stop_event({ ui.on_stop_event({
let ui_weak = ui.as_weak(); let ui_weak = ui.as_weak();
let log = writing_log.clone(); let log = writing_log.clone();
let config = config.clone();
move || { move || {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let state = ui.get_record_state(); let events_rc = ui.get_record_events();
let events = state.events.as_any() let events = events_rc.as_any()
.downcast_ref::<VecModel<TimelineEvent>>() .downcast_ref::<VecModel<TimelineEvent>>()
.unwrap(); .unwrap();
let offset = ui.get_record_offset();
let event_id = events.iter() let event_id = events.iter()
.position(|data| !data.finished) .position(|data| !data.finished)
.unwrap(); .unwrap();
let event = events.row_data(event_id) let event = events.row_data(event_id)
.expect("stop-event called without unfinished events"); .expect("stop-event called without unfinished events");
let (background_color, text_color) = TimelineEvent::colors(&config.colors, event.label.as_str());
let new_event = TimelineEvent { let new_event = TimelineEvent {
duration: state.offset - event.start, duration: offset - event.start,
finished: true, finished: true,
label: event.label.clone(), label: event.label.clone(),
start: event.start, start: event.start,
background_color, color_id: color_id_from_name(event.label.to_string())
text_color,
}; };
{ {
@ -274,7 +261,6 @@ fn main() -> Result<(), Box<dyn Error>> {
ui.on_new_day_started({ ui.on_new_day_started({
let ui_weak = ui.as_weak(); let ui_weak = ui.as_weak();
let log = writing_log.clone(); let log = writing_log.clone();
let config = config.clone();
move || { move || {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
@ -309,7 +295,7 @@ fn main() -> Result<(), Box<dyn Error>> {
} }
} }
load_log(ui.as_weak(), log.clone(), config.clone()); load_log(ui.as_weak(), log.clone());
ui.invoke_save_log(); ui.invoke_save_log();
} }
}); });

View file

@ -1,106 +1,61 @@
import { TabWidget, VerticalBox, ComboBox } from "std-widgets.slint"; import { TabWidget } from "std-widgets.slint";
import { RecordWidget } from "record.slint"; import { RecordWidget } from "record.slint";
import { ReviewWidget } from "review.slint"; import { ReviewWidget } from "review.slint";
import { TimelineEvent, Timeline, TimelineState } from "timeline.slint"; import { TimelineEvent } from "timeline.slint";
import { Const } from "global.slint";
export { Palette } from "theme.slint"; export { Palette } from "theme.slint";
export component AppWindow inherits Window { export component AppWindow inherits Window {
callback update-record-offset(int);
callback save-log;
callback new-day-started();
update-record-offset(new-offset) => {
record-state.offset = new-offset;
}
preferred-width: 800px;
preferred-height: 600px;
max-width: 2147483647px;
max-height: 2147483647px;
callback start-new-event <=> record.start-new-event; callback start-new-event <=> record.start-new-event;
callback stop-event <=> record.stop-event; callback stop-event <=> record.stop-event;
callback chain-event <=> record.chain-event; callback chain-event <=> record.chain-event;
callback new-day-started <=> record.new-day-started;
callback get-previous-event <=> record.get-previous-event; callback get-previous-event <=> record.get-previous-event;
callback update-record-offset(int);
callback update-window-size(length, length);
callback save-log;
callback fetch-log <=> review.fetch-log; callback fetch-log <=> review.fetch-log;
callback update-visible-time(bool, string); callback update-visible-time(bool, string);
in-out property record-state <=> record.state; update-record-offset(new-offset) => {
record.offset = new-offset;
}
in-out property review-state <=> review.state; update-window-size(width, height) => {
self.width = width;
self.height = height;
}
in-out property record-events <=> record.events;
in-out property record-offset <=> record.offset;
in-out property record-visible-time <=> record.visible-time;
in-out property in-progress <=> record.in-progress; in-out property in-progress <=> record.in-progress;
in property previous-event-name <=> record.previous-event-name; in property previous-event-name <=> record.previous-event-name;
in-out property review-events <=> review.events;
in-out property review-offset <=> review.offset;
in-out property review-visible-time <=> review.visible-time;
property<[string]> combo-spans: ["1 Hour", "4 Hours", "8 Hours", "24 Hours"]; property<[string]> combo-spans: ["1 Hour", "4 Hours", "8 Hours", "24 Hours"];
property<bool> minimized: false;
property<bool> in-record-mode: true;
init => {
record-state.visible-time = 3600;
review-state.visible-time = 3600;
}
Timer {
interval: 1s;
running: true;
triggered => {
if (record-state.offset >= Const.max-offset) {
root.new-day-started();
record-state.offset = 0;
return;
}
record-state.offset += 1;
}
}
title: "Aliveline"; title: "Aliveline";
VerticalLayout { TabWidget {
width: 100%; Tab {
height: 100%; title: "Record";
tl := Timeline { record := RecordWidget {
preferred-height: 100%; combo-spans: combo-spans;
min-height: 50px; update-visible-time(time) => {
state: in-record-mode ? record-state : review-state; root.update-visible-time(true, time);
clicked => { }
minimized = !minimized;
} }
} }
spacing: minimized ? 0 : 8px; Tab {
record := RecordWidget { title: "Review";
combo-spans: combo-spans; review := ReviewWidget {
update-visible-time(time) => { combo-spans: combo-spans;
root.update-visible-time(true, time); update-visible-time(time) => {
} root.update-visible-time(false, time)
minimized: minimized || !in-record-mode;
}
review := ReviewWidget {
combo-spans: combo-spans;
update-visible-time(time) => {
root.update-visible-time(false, time)
}
minimized: minimized;
is-active: !in-record-mode;
}
if !minimized: HorizontalLayout {
padding-left: 8px;
padding-right: 8px;
padding-bottom: 8px;
spacing: 16px;
Text {
text: "Mode:";
font-size: 24px;
horizontal-alignment: right;
}
ComboBox {
model: ["Record", "Review"];
current-index: in-record-mode ? 0 : 1;
selected(current-value) => {
in-record-mode = current-value == "Record";
} }
} }
} }

View file

@ -1,21 +0,0 @@
export global TimeString {
pure function pad-mh(seconds: int, param: int) -> string {
if seconds / param < 10 {
return "0\{floor(seconds / param)}";
}
return "\{floor(seconds / param)}";
}
pure function pad-s(seconds: int) -> string {
if mod(seconds, 60) < 10 {
return "0\{mod(seconds, 60)}";
}
return "\{mod(seconds, 60)}";
}
public pure function from(seconds: int) -> string {
return "\{pad-mh(seconds, 3600)}:\{pad-mh(mod(seconds, 3600), 60)}:\{pad-s(seconds)}";
}
}
export global Const {
out property <int> max-offset: 24 * 3600 - 1;
}

View file

@ -1,25 +1,33 @@
import { VerticalBox, LineEdit, Button, ComboBox } from "std-widgets.slint"; import { VerticalBox, LineEdit, Button, ComboBox } from "std-widgets.slint";
import { Timeline, TimelineState } from "timeline.slint"; import { Timeline } from "timeline.slint";
export component RecordWidget inherits VerticalLayout { export component RecordWidget inherits VerticalBox {
callback new-day-started <=> tl.new-day-started;
callback update-visible-time(string); callback update-visible-time(string);
callback start-new-event(string); callback start-new-event(string);
callback chain-event(string); callback chain-event(string);
callback stop-event; callback stop-event;
callback get-previous-event(); callback get-previous-event();
in-out property visible-time <=> tl.visible-time;
in-out property<TimelineState> state; in-out property updating <=> tl.updating;
in-out property offset <=> tl.offset;
in-out property events <=> tl.events;
in property<[string]> combo-spans: []; in property<[string]> combo-spans: [];
in-out property<bool> in-progress: false; in-out property<bool> in-progress: false;
property<string> event-name: ""; property<string> event-name: "";
in property<string> previous-event-name: ""; in property<string> previous-event-name: "";
property<bool> minimized: false;
property<int> combo-index: 0; property<int> combo-index: 0;
in-out property <bool> minimized; tl := Timeline {
preferred-height: 100%;
updating: true;
clicked => {
minimized = !minimized;
}
}
if !minimized: GridLayout { if !minimized: GridLayout {
spacing-vertical: 8px; spacing-vertical: 8px;
spacing-horizontal: 16px; spacing-horizontal: 16px;
padding: 8px;
le := LineEdit { le := LineEdit {
placeholder-text: "Event name"; placeholder-text: "Event name";
text: event-name; text: event-name;
@ -35,7 +43,6 @@ export component RecordWidget inherits VerticalLayout {
text: in-progress ? "Stop" : "Start"; text: in-progress ? "Stop" : "Start";
row: 1; row: 1;
colspan: 2; colspan: 2;
primary: true;
clicked => { clicked => {
if in-progress { if in-progress {
root.stop-event(); root.stop-event();
@ -49,7 +56,7 @@ export component RecordWidget inherits VerticalLayout {
Button { Button {
text: "Chain"; text: "Chain";
enabled: in-progress; enabled: in-progress;
col: 2; col: 3;
row: 1; row: 1;
colspan: 2; colspan: 2;
clicked => { clicked => {
@ -60,7 +67,7 @@ export component RecordWidget inherits VerticalLayout {
Button { Button {
text: previous-event-name == "" ? "Chain previous event (None)" : "Chain previous event (\{previous-event-name})"; text: previous-event-name == "" ? "Chain previous event (None)" : "Chain previous event (\{previous-event-name})";
enabled: in-progress && previous-event-name != ""; enabled: in-progress && previous-event-name != "";
col: 4; col: 5;
row: 1; row: 1;
colspan: 2; colspan: 2;
clicked => { clicked => {
@ -70,7 +77,7 @@ export component RecordWidget inherits VerticalLayout {
} }
} }
Text { Text {
text: "Span: "; text: "Span:";
font-size: 24px; font-size: 24px;
row: 2; row: 2;
colspan: 3; colspan: 3;

View file

@ -1,59 +1,62 @@
import { VerticalBox, LineEdit, Button, DatePickerPopup, ComboBox, Slider } from "std-widgets.slint"; import { VerticalBox, LineEdit, Button, DatePickerPopup, ComboBox, Slider } from "std-widgets.slint";
import { Timeline, TimelineState } from "timeline.slint"; import { Timeline } from "timeline.slint";
import { Const } from "global.slint";
export component ReviewWidget inherits VerticalLayout { export component ReviewWidget inherits VerticalBox {
callback update-visible-time(string); callback update-visible-time(string);
callback fetch-log(int, int, int); callback fetch-log(int, int, int);
property<int> max-offset: 24 * 3600;
property<int> current-year; property<int> current-year;
property<int> current-month; property<int> current-month;
property<int> current-day; property<int> current-day;
in property<[string]> combo-spans: []; in property<[string]> combo-spans: [];
in-out property<TimelineState> state; in-out property visible-time <=> tl.visible-time;
in-out property<bool> minimized; in-out property offset <=> tl.offset;
in-out property<bool> is-active; in-out property events <=> tl.events;
if is-active: VerticalLayout { tl := Timeline {
spacing: 8px; updating: false;
}
GridLayout {
spacing-vertical: 8px;
spacing-horizontal: 16px;
Slider { Slider {
minimum: state.visible-time; minimum: visible-time;
maximum: Const.max-offset; maximum: tl.max-offset;
value: state.offset; value: offset;
row: 0;
colspan: 2;
changed(value) => { changed(value) => {
state.offset = value; offset = value;
} }
} }
if !minimized: GridLayout { Text {
spacing-horizontal: 16px; text: "Day: \{current-day}/\{current-month}/\{current-year}";
padding: 8px; font-size: 32px;
Text { horizontal-alignment: right;
text: "Day: \{current-day}/\{current-month}/\{current-year}"; row: 1;
font-size: 24px; }
horizontal-alignment: right; Button {
text: "Select";
clicked => {
date-picker.show()
} }
Button { row: 1;
primary: true; col: 1;
text: "Select"; }
clicked => { Text {
date-picker.show() text: "Span: ";
} font-size: 24px;
col: 1; row: 2;
} horizontal-alignment: right;
Text { }
text: "Span: "; ComboBox {
font-size: 24px; model: combo-spans;
row: 1; current-index: 0;
horizontal-alignment: right; row: 2;
} col: 1;
ComboBox { selected(current-value) => {
model: combo-spans; root.update-visible-time(current-value);
current-index: 0;
row: 1;
col: 1;
selected(current-value) => {
root.update-visible-time(current-value);
}
} }
} }
} }

View file

@ -2,4 +2,42 @@ export global Palette {
in-out property<color> background: gray; in-out property<color> background: gray;
in-out property<color> timeline: darkgray; in-out property<color> timeline: darkgray;
in-out property<color> background-text: black; 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,29 +1,55 @@
import { Palette } from "theme.slint"; import { Palette } from "theme.slint";
import { TimeString, Const } from "global.slint";
export struct TimelineEvent { export struct TimelineEvent {
start: int, start: int,
duration: int, duration: int,
finished: bool, finished: bool,
label: string, label: string,
background-color: color, color-id: int
text-color: color,
} }
export struct TimelineState { global TimeString {
visible-time: int, pure function pad-mh(seconds: int, param: int) -> string {
offset: int, if seconds / param < 10 {
events: [TimelineEvent], return "0\{floor(seconds / param)}";
}
return "\{floor(seconds / param)}";
}
pure function pad-s(seconds: int) -> string {
if mod(seconds, 60) < 10 {
return "0\{mod(seconds, 60)}";
}
return "\{mod(seconds, 60)}";
}
public pure function from(seconds: int) -> string {
return "\{pad-mh(seconds, 3600)}:\{pad-mh(mod(seconds, 3600), 60)}:\{pad-s(seconds)}";
}
} }
export component Timeline inherits Rectangle { export component Timeline inherits Rectangle {
callback new-day-started;
callback clicked <=> ta.clicked; callback clicked <=> ta.clicked;
background: Palette.background; background: Palette.background;
in-out property<TimelineState> state; in-out property<bool> updating: true;
property<int> visible-offset: max(state.offset, state.visible-time); in-out property<[TimelineEvent]> events: [];
out property<int> max-offset: Const.max-offset; 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 { ta := TouchArea {
preferred-width: 100%; preferred-width: 100%;
@ -46,7 +72,7 @@ export component Timeline inherits Rectangle {
Text { Text {
x: 0; x: 0;
y: parent.height - self.height; y: parent.height - self.height;
text: TimeString.from(visible-offset - state.visible-time); text: TimeString.from(visible-offset - visible-time);
color: Palette.background-text; color: Palette.background-text;
} }
@ -57,9 +83,9 @@ export component Timeline inherits Rectangle {
color: Palette.background-text; color: Palette.background-text;
} }
for event in state.events: timeline-event := Rectangle { for event in events: timeline-event := Rectangle {
property<length> real-x: ((state.visible-time - (visible-offset - event.start)) / state.visible-time) * parent.width; property<length> real-x: ((visible-time - (visible-offset - event.start)) / visible-time) * parent.width;
property<length> real-width: event.duration / state.visible-time * parent.width + min(real-x, 0); property<length> real-width: event.duration / visible-time * parent.width + min(real-x, 0);
x: max(real-x, 0); x: max(real-x, 0);
y: parent.height / 4; y: parent.height / 4;
z: 1; z: 1;
@ -70,7 +96,7 @@ export component Timeline inherits Rectangle {
visible: self.width > 0 && self.real-x < parent.width; visible: self.width > 0 && self.real-x < parent.width;
border-color: black; border-color: black;
border-width: 1px; border-width: 1px;
background: event.background-color; background: Palette.event-colors[event.color-id];
Text { Text {
x: 0; x: 0;
@ -84,11 +110,11 @@ export component Timeline inherits Rectangle {
y: root.height - self.height - timeline-event.height; y: root.height - self.height - timeline-event.height;
text: timeline-event.x == timeline-event.real-x ? text: timeline-event.x == timeline-event.real-x ?
TimeString.from(event.start) : TimeString.from(event.start) :
TimeString.from(visible-offset - state.visible-time); TimeString.from(visible-offset - visible-time);
visible: timeline-event.visible && visible: timeline-event.visible &&
(self.width * 2 < timeline-event.width || (self.width * 2 < timeline-event.width ||
(!end-txt.visible && self.width < timeline-event.width)); (!end-txt.visible && self.width < timeline-event.width));
color: event.text-color; color: Palette.event-text[event.color-id];
} }
end-txt := Text { end-txt := Text {
x: timeline-event.width - self.width; x: timeline-event.width - self.width;
@ -97,7 +123,7 @@ export component Timeline inherits Rectangle {
TimeString.from(event.start + event.duration) : TimeString.from(event.start + event.duration) :
TimeString.from(visible-offset); TimeString.from(visible-offset);
visible: timeline-event.visible && timeline-event.width - self.width * 2 > 0; visible: timeline-event.visible && timeline-event.width - self.width * 2 > 0;
color: event.text-color; color: Palette.event-text[event.color-id];
} }
} }
@children @children