ui: Mass layout refactor

- Replaced TabWidget mode selection with ComboBox
- Moved Timeline out of record/review widgets
- Added TimelineState struct
- Set slint style to cosmic
This commit is contained in:
Alexey 2026-04-08 11:12:49 +03:00
commit 1a1f6dde83
7 changed files with 190 additions and 160 deletions

View file

@ -1,3 +1,6 @@
fn main() { fn main() {
slint_build::compile("ui/app-window.slint").expect("Slint build failed"); let config = slint_build::CompilerConfiguration::new()
.with_style("cosmic".into());
slint_build::compile_with_config("ui/app-window.slint", config).expect("Slint build failed");
} }

View file

@ -8,9 +8,6 @@ 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 From<Event> for TimelineEvent { impl From<Event> for TimelineEvent {
@ -60,7 +57,9 @@ fn load_log(ui_weak: Weak<AppWindow>, log: Arc<Mutex<Log>>) {
.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();
ui.set_record_events(model); let mut state = ui.get_record_state();
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();
} }
@ -109,7 +108,6 @@ fn main() -> Result<(), Box<dyn Error>> {
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();
@ -127,7 +125,9 @@ fn main() -> Result<(), Box<dyn Error>> {
.map(|event| TimelineEvent::from((*event).clone())) .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();
ui.set_review_events(model); let mut state = ui.get_review_state();
state.events = model;
ui.set_review_state(state);
} }
}); });
@ -151,10 +151,14 @@ 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 {
ui.set_record_visible_time(hours * 3600); let mut state = ui.get_record_state();
state.visible_time = hours * 3600;
ui.set_record_state(state);
} else { } else {
ui.set_review_visible_time(hours * 3600); let mut state = ui.get_review_state();
} state.visible_time = hours * 3600;
ui.set_review_state(state);
};
} }
}); });
@ -164,17 +168,16 @@ fn main() -> Result<(), Box<dyn Error>> {
move |event_name: SharedString| { move |event_name: SharedString| {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let events_rc = ui.get_record_events(); let state = ui.get_record_state();
let events = events_rc.as_any() let events = state.events.as_any()
.downcast_ref::<VecModel<TimelineEvent>>() .downcast_ref::<VecModel<TimelineEvent>>()
.unwrap(); .unwrap();
let offset = ui.get_record_offset();
let event = TimelineEvent { let event = TimelineEvent {
duration: 0, duration: 0,
finished: false, finished: false,
label: event_name.clone(), label: event_name.clone(),
start: offset, start: state.offset,
color_id: color_id_from_name(event_name.to_string()) color_id: color_id_from_name(event_name.to_string())
}; };
@ -194,11 +197,10 @@ fn main() -> Result<(), Box<dyn Error>> {
let log = writing_log.clone(); let log = writing_log.clone();
move || { move || {
let ui = ui_weak.unwrap(); let ui = ui_weak.unwrap();
let events_rc = ui.get_record_events(); let state = ui.get_record_state();
let events = events_rc.as_any() let events = state.events.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)
@ -206,7 +208,7 @@ fn main() -> Result<(), Box<dyn Error>> {
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 new_event = TimelineEvent { let new_event = TimelineEvent {
duration: offset - event.start, duration: state.offset - event.start,
finished: true, finished: true,
label: event.label.clone(), label: event.label.clone(),
start: event.start, start: event.start,

View file

@ -1,62 +1,105 @@
import { TabWidget } from "std-widgets.slint"; import { TabWidget, VerticalBox, ComboBox } 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 } from "timeline.slint"; import { TimelineEvent, Timeline, TimelineState } 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;
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);
update-record-offset(new-offset) => { in-out property record-state <=> record.state;
record.offset = new-offset;
}
update-window-size(width, height) => { in-out property review-state <=> review.state;
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";
TabWidget { VerticalLayout {
Tab { width: 100%;
title: "Record"; height: 100%;
tl := Timeline {
preferred-height: 100%;
state: in-record-mode ? record-state : review-state;
clicked => {
minimized = !minimized;
}
}
spacing: minimized ? 0 : 8px;
record := RecordWidget { record := RecordWidget {
combo-spans: combo-spans; combo-spans: combo-spans;
update-visible-time(time) => { update-visible-time(time) => {
root.update-visible-time(true, time); root.update-visible-time(true, time);
} }
minimized: minimized || !in-record-mode;
} }
}
Tab {
title: "Review";
review := ReviewWidget { review := ReviewWidget {
combo-spans: combo-spans; combo-spans: combo-spans;
update-visible-time(time) => { update-visible-time(time) => {
root.update-visible-time(false, 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";
}
} }
} }
} }

21
ui/global.slint Normal file
View file

@ -0,0 +1,21 @@
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,33 +1,25 @@
import { VerticalBox, LineEdit, Button, ComboBox } from "std-widgets.slint"; import { VerticalBox, LineEdit, Button, ComboBox } from "std-widgets.slint";
import { Timeline } from "timeline.slint"; import { Timeline, TimelineState } from "timeline.slint";
export component RecordWidget inherits VerticalBox { export component RecordWidget inherits VerticalLayout {
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 updating <=> tl.updating; in-out property<TimelineState> state;
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;
tl := Timeline { in-out property <bool> minimized;
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;
@ -56,7 +48,7 @@ export component RecordWidget inherits VerticalBox {
Button { Button {
text: "Chain"; text: "Chain";
enabled: in-progress; enabled: in-progress;
col: 3; col: 2;
row: 1; row: 1;
colspan: 2; colspan: 2;
clicked => { clicked => {
@ -67,7 +59,7 @@ export component RecordWidget inherits VerticalBox {
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: 5; col: 4;
row: 1; row: 1;
colspan: 2; colspan: 2;
clicked => { clicked => {

View file

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

View file

@ -1,55 +1,28 @@
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,
color-id: int color-id: int,
} }
global TimeString { export struct TimelineState {
pure function pad-mh(seconds: int, param: int) -> string { visible-time: int,
if seconds / param < 10 { offset: int,
return "0\{floor(seconds / param)}"; events: [TimelineEvent],
}
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<bool> updating: true; in-out property<TimelineState> state;
in-out property<[TimelineEvent]> events: []; property<int> visible-offset: max(state.offset, state.visible-time);
in-out property<int> visible-time: 3600; out property<int> max-offset: Const.max-offset;
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%;
@ -72,7 +45,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 - visible-time); text: TimeString.from(visible-offset - state.visible-time);
color: Palette.background-text; color: Palette.background-text;
} }
@ -83,9 +56,9 @@ export component Timeline inherits Rectangle {
color: Palette.background-text; color: Palette.background-text;
} }
for event in events: timeline-event := Rectangle { for event in state.events: timeline-event := Rectangle {
property<length> real-x: ((visible-time - (visible-offset - event.start)) / visible-time) * parent.width; property<length> real-x: ((state.visible-time - (visible-offset - event.start)) / state.visible-time) * parent.width;
property<length> real-width: event.duration / visible-time * parent.width + min(real-x, 0); property<length> real-width: event.duration / state.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;
@ -110,7 +83,7 @@ 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 - visible-time); TimeString.from(visible-offset - state.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));