Compare commits

..

12 Commits

Author SHA1 Message Date
cdo b2f5288c4b Remove sorting from server
The client is doing it itself now anyway.
2025-03-03 01:06:43 +00:00
cdo 8123c5375d Regularly refresh activities and update cards in-place 2025-03-03 01:04:56 +00:00
cdo 3e99cc293a Create Refresher class (and IRefreshable interface) 2025-03-02 18:16:08 +00:00
cdo e275fa01ed Add start() method to Periodic (and construct stopped) 2025-03-02 18:16:04 +00:00
cdo 487427cf0a Add connected property to Client 2025-03-02 17:05:16 +00:00
cdo 5912302043 Add connection indicator to client 2025-03-02 16:53:11 +00:00
cdo 7c26a9278f Transfer ownership of client socket in session_server:init/1 2025-03-02 16:53:11 +00:00
cdo 8a7032309f Replace deprecated CSS functions with standard ones 2025-03-02 16:53:11 +00:00
cdo 708343c37f Add connection_status signal to Client 2025-03-02 16:53:08 +00:00
cdo 10a7fe5c82 Remove refreshing indicator from ActivitiesView
I've decided I'm going to add an indicator for when the client is
disconnected so the indicator would be pretty redundant (and it
requires a bunch of code to implement).
2025-03-02 15:14:43 +00:00
cdo c2d81778a8 Extract Card and CardArea classes 2025-03-02 14:47:09 +00:00
cdo 94df48db7b Extract Periodic class from connection's Worker 2025-03-02 14:03:33 +00:00
14 changed files with 348 additions and 139 deletions
+81 -74
View File
@@ -1,59 +1,31 @@
namespace StudySystemClient {
public class ActivitiesView : Gtk.Box {
public class ActivitiesView : CardArea<ActivityCard>, IRefreshable {
private const uint REFRESH_PERIOD_MS = 30000;
private Client client;
private Gtk.FlowBox card_container;
private RefreshingIndicator refreshing_indicator;
private Refresher refresher;
private bool pending_sort;
public ActivitiesView(Client client) {
Object(orientation: Gtk.Orientation.VERTICAL,
hexpand: true,
vexpand: true,
margin_top: 0,
margin_bottom: 0,
margin_start: 0,
margin_end: 0);
this.client = client;
card_container = new Gtk.FlowBox();
card_container.homogeneous = true;
card_container.min_children_per_line = 1;
card_container.max_children_per_line = 1;
card_container.selection_mode = Gtk.SelectionMode.NONE;
card_container.valign = Gtk.Align.START;
var scrolled_window = new Gtk.ScrolledWindow();
scrolled_window.hscrollbar_policy = Gtk.PolicyType.NEVER;
scrolled_window.hexpand = true;
scrolled_window.vexpand = true;
scrolled_window.add_css_class("card-container");
scrolled_window.set_child(card_container);
var overlay = new Gtk.Overlay();
overlay.hexpand = overlay.vexpand = true;
overlay.set_child(scrolled_window);
this.append(overlay);
refreshing_indicator = new RefreshingIndicator(overlay);
this.map.connect(refresh);
refresher = new Refresher(this, REFRESH_PERIOD_MS);
pending_sort = false;
this.map.connect(() => {
refresher.start();
refresh.begin();
});
}
private async void refresh() {
refreshing_indicator.show();
if (!client.connected)
return;
try {
var activities = yield client.list_activities();
card_container.remove_all();
foreach (var activity in activities) {
var card = new ActivityCard(activity);
card.session_logged.connect(log_session);
card_container.append(card);
}
update_cards(activities);
} catch (ClientError e) {
stderr.printf("Error refreshing activities: %s\n",
e.message);
}
refreshing_indicator.hide();
}
private async void log_session(string subject, ActivityType type,
@@ -65,43 +37,78 @@ namespace StudySystemClient {
stderr.printf("Error logging session: %s\n", e.message);
}
}
}
private class RefreshingIndicator {
private Gtk.Overlay overlay;
private Gtk.Frame frame;
public RefreshingIndicator(Gtk.Overlay overlay) {
this.overlay = overlay;
var label = new Gtk.Label("Refreshing");
label.halign = Gtk.Align.START;
var spinner = new Gtk.Spinner();
spinner.start();
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
content.margin_top = content.margin_bottom
= content.margin_start = content.margin_end = 12;
content.append(label);
content.append(spinner);
frame = new Gtk.Frame(null);
frame.halign = Gtk.Align.CENTER;
frame.valign = Gtk.Align.START;
frame.add_css_class("osd");
frame.add_css_class("popdown");
frame.set_child(content);
overlay.add_overlay(frame);
private void handle_pending_sort() {
if (pending_sort) {
container.sort(compare_cards);
pending_sort = false;
}
}
public void show() {
frame.add_css_class("visible");
private void update_cards(Array<Activity> activities) {
update_existing_cards(activities);
for (uint i = 0; i < activities.length; ++i)
create_card(activities.index(i));
if (log_in_progress())
pending_sort = true;
else
container.sort(compare_cards);
}
public void hide() {
frame.remove_css_class("visible");
private void update_existing_cards(Array<Activity> activities) {
var to_remove = new List<ActivityCard>();
foreach (var card in container) {
if (!update_existing_card(card, activities))
to_remove.append(card);
}
foreach (var card in to_remove)
container.remove(card);
}
private bool update_existing_card(ActivityCard card,
Array<Activity> activities) {
var activity_index = find_activity(card, activities);
if (activity_index == null)
return false;
var priority = activities.index(activity_index).priority;
card.update_priority(priority);
activities._remove_index_fast(activity_index);
return true;
}
private uint? find_activity(ActivityCard card,
Array<Activity> activities) {
for (uint i = 0; i < activities.length; ++i) {
if (activities.index(i).subject == card.activity.subject
&& activities.index(i).type == card.activity.type)
return i;
}
return null;
}
private void create_card(Activity activity) {
var card = new ActivityCard(activity);
card.session_logged.connect(log_session);
card.log_closed.connect(handle_pending_sort);
container.append(card);
}
private bool log_in_progress() {
foreach (var card in container) {
if (card.logging)
return true;
}
return false;
}
private static int compare_cards(ActivityCard card1,
ActivityCard card2) {
if (card1.activity.priority < card2.activity.priority)
return -1;
else if (card1.activity.priority > card2.activity.priority)
return 1;
else
return 0;
}
}
}
+33 -6
View File
@@ -1,10 +1,19 @@
namespace StudySystemClient {
public class ActivityCard : Gtk.Frame {
public class ActivityCard : Card {
public signal void session_logged(string subject, ActivityType type,
int minutes);
public signal void log_closed();
public Activity activity { get; private set; }
public bool logging { get; private set; }
private Gtk.Label priority_label;
public ActivityCard(Activity activity) {
add_css_class("card");
base();
this.activity = activity;
logging = false;
var subject = new Gtk.Label(activity.subject);
subject.halign = Gtk.Align.START;
@@ -14,13 +23,14 @@ namespace StudySystemClient {
type.add_css_class("activity-type");
var separator = new Gtk.Label("·");
separator.add_css_class("activity-priority");
var priority = new Gtk.Label("%0.2f".printf(activity.priority));
priority.add_css_class("activity-priority");
priority_label = new Gtk.Label(priority_text(activity.priority));
priority_label.add_css_class("activity-priority");
var details = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 6);
details.append(type);
details.append(separator);
details.append(priority);
details.append(priority_label);
var text = new Gtk.Box(Gtk.Orientation.VERTICAL, 6);
text.hexpand = true;
@@ -42,10 +52,27 @@ namespace StudySystemClient {
var log_session_popover = new LogSessionPopover();
log_session_popover.set_parent(button);
button.clicked.connect(() => log_session_popover.popup());
log_session_popover.closed.connect(() => {
logging = false;
log_closed();
});
log_session_popover.session_logged.connect((minutes) => {
session_logged(activity.subject, activity.type, minutes);
});
button.clicked.connect(() => {
logging = true;
log_session_popover.popup();
});
}
public void update_priority(double new_priority) {
activity = { activity.subject, activity.type, new_priority };
priority_label.set_text(priority_text(new_priority));
}
private static string priority_text(double priority) {
return "%0.2f".printf(priority);
}
}
+28
View File
@@ -0,0 +1,28 @@
namespace StudySystemClient {
public class Card : Gtk.Frame {
public Card() {
hexpand = true;
add_css_class("card");
}
}
public class CardArea<T> : Gtk.Box {
protected IterableBox<T> container;
public CardArea() {
hexpand = vexpand = true;
margin_top = margin_bottom = margin_start = margin_end = 0;
container = new IterableBox<T>(Gtk.Orientation.VERTICAL, 6);
container.valign = Gtk.Align.START;
var scrolled_window = new Gtk.ScrolledWindow();
scrolled_window.hscrollbar_policy = Gtk.PolicyType.NEVER;
scrolled_window.hexpand = scrolled_window.vexpand = true;
scrolled_window.add_css_class("card-container");
scrolled_window.set_child(container);
append(scrolled_window);
}
}
}
+7 -1
View File
@@ -5,10 +5,16 @@ namespace StudySystemClient {
}
public class Client {
public signal void connection_status(bool connected);
public bool connected { get { return connection.connected; } }
private Connection connection;
public Client(string cert_dir) throws Error {
connection = new Connection(cert_dir);
connection = new Connection(cert_dir, (connected) => {
connection_status(connected);
});
}
public async void ping() throws ClientError {
+28 -18
View File
@@ -1,17 +1,27 @@
namespace StudySystemClient {
public class Connection {
public delegate void StatusCallback(bool connected);
public bool connected { get; private set; }
private StatusCallback status_callback;
private SessionManager session_manager;
private TransactionManager transaction_manager;
private Worker worker;
public Connection(string cert_dir) throws Error {
public Connection(string cert_dir,
owned StatusCallback status_callback)
throws Error {
var loopback = new InetAddress.loopback(SocketFamily.IPV6);
var session_factory
= new SessionFactory(loopback, 12888, cert_dir);
this.status_callback = (owned) status_callback;
session_manager = new SessionManager(
session_factory, (msg) => receive(msg));
session_factory, (msg) => receive(msg),
(connected) => update_status(connected));
transaction_manager = new TransactionManager();
worker = new Worker(session_manager);
connected = true;
}
public async Response.Body? send(Request.Body body) {
@@ -35,6 +45,16 @@ namespace StudySystemClient {
return false;
}, GLib.Priority.DEFAULT_IDLE);
}
private void update_status(bool connected) {
Idle.add(() => {
if (connected != this.connected) {
this.connected = connected;
status_callback(connected);
}
return false;
}, GLib.Priority.DEFAULT_IDLE);
}
}
private class Continuation {
@@ -84,29 +104,19 @@ namespace StudySystemClient {
}
}
private class Worker {
private uint TASK_PERIOD_MS = 10;
private class Worker : Periodic {
private const uint TASK_PERIOD_MS = 10;
private SessionManager session_manager;
private bool exit;
private Thread<void> thread;
public Worker(SessionManager session_manager) {
base(TASK_PERIOD_MS);
this.session_manager = session_manager;
exit = false;
thread = new Thread<void>("connection_worker", body);
start();
}
~Worker() {
exit = true;
thread.join();
}
private void body() {
while (!exit) {
session_manager.task();
Thread.usleep(1000 * TASK_PERIOD_MS);
}
protected override void task() {
session_manager.task();
}
}
}
+52
View File
@@ -0,0 +1,52 @@
namespace StudySystemClient {
public class IterableBox<T> : Gtk.Box {
private List<T> elements;
public IterableBox(Gtk.Orientation orientation, int spacing) {
this.orientation = orientation;
this.spacing = spacing;
elements = new List<T>();
}
public new void append(T element) {
elements.append(element);
base.append(element as Gtk.Widget);
}
public new void remove(T element) {
elements.remove(element);
base.remove(element as Gtk.Widget);
}
public Iterator<Type, T> iterator() {
return new Iterator<Type, T>(elements);
}
public void sort(CompareDataFunc<T> comparison) {
elements.sort_with_data(comparison);
foreach (var element in elements)
reorder_child_after(element as Gtk.Widget, null);
}
public class Iterator<Type, T> {
private unowned List<T> head;
private unowned T value;
public Iterator(List<T> elements) {
head = elements;
}
public bool next() {
if (head.is_empty())
return false;
value = head.data;
head = head.next;
return true;
}
public unowned T get() {
return value;
}
}
}
}
+31 -1
View File
@@ -12,8 +12,38 @@ namespace StudySystemClient {
header_bar.title_widget = title;
set_titlebar(header_bar);
var connection_indicator = new ConnectionIndicator(client);
var activities_view = new ActivitiesView(client);
set_child(activities_view);
var content = new Gtk.Box(Gtk.Orientation.VERTICAL, 0);
content.append(connection_indicator);
content.append(activities_view);
set_child(content);
}
}
private class ConnectionIndicator : Gtk.Box {
public ConnectionIndicator(Client client) {
var icon
= new Gtk.Image.from_icon_name("network-offline-symbolic");
var label = new Gtk.Label("Disconnected");
var content = new Gtk.Box(Gtk.Orientation.HORIZONTAL, 8);
content.margin_top = content.margin_bottom
= content.margin_start = content.margin_end = 12;
content.hexpand = true;
content.halign = Gtk.Align.CENTER;
content.append(icon);
content.append(label);
var revealer = new Gtk.Revealer();
revealer.set_child(content);
append(revealer);
client.connection_status.connect((connected) => {
revealer.reveal_child = !connected;
});
}
}
}
+4
View File
@@ -13,10 +13,14 @@ lib = library(
'activities_view.vala',
'activity.vala',
'activity_card.vala',
'card.vala',
'client.vala',
'connection.vala',
'der.vala',
'iterable_box.vala',
'main_window.vala',
'periodic.vala',
'refresher.vala',
'request.vala',
'response.vala',
'session_manager.vala',
+35
View File
@@ -0,0 +1,35 @@
namespace StudySystemClient {
public abstract class Periodic {
private uint period_ms;
private bool exit;
private Thread<void> thread;
protected Periodic(uint period_ms) {
this.period_ms = period_ms;
exit = true;
}
~Periodic() {
if (!exit) {
exit = true;
thread.join();
}
}
protected abstract void task();
public void start() {
if (exit) {
exit = false;
thread = new Thread<void>("Periodic task", body);
}
}
private void body() {
while (!exit) {
task();
Thread.usleep(1000 * period_ms);
}
}
}
}
+23
View File
@@ -0,0 +1,23 @@
namespace StudySystemClient {
public interface IRefreshable {
public abstract async void refresh();
}
private class Refresher : Periodic {
private weak IRefreshable target;
public Refresher(IRefreshable target, uint period_ms) {
base(period_ms);
this.target = target;
}
protected override void task() {
if (target != null) {
Idle.add(() => {
target.refresh.begin();
return false;
});
}
}
}
}
+11 -4
View File
@@ -3,19 +3,23 @@ namespace StudySystemClient {
public delegate void ReceiveCallback(owned uint8[] msg);
private const uint INIT_RECONNECT_WAIT_MS = 500;
private const uint MAX_RECONNECT_WAIT_MS = 60000;
private const uint MAX_RECONNECT_WAIT_MS = 30000;
private const double RECONNECT_BACKOFF = 1.6;
private SessionFactory session_factory;
private ReceiveCallback receive_callback;
private Connection.StatusCallback status_callback;
private Session? session;
private AsyncQueue<OutgoingMessage> queue;
private uint reconnect_wait_ms;
public SessionManager(SessionFactory session_factory,
owned ReceiveCallback receive_callback) {
public SessionManager(
SessionFactory session_factory,
owned ReceiveCallback receive_callback,
owned Connection.StatusCallback status_callback) {
this.session_factory = session_factory;
this.receive_callback = (owned) receive_callback;
this.status_callback = (owned) status_callback;
this.session = null;
queue = new AsyncQueue<OutgoingMessage>();
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
@@ -40,6 +44,7 @@ namespace StudySystemClient {
if (msg.should_retry())
queue.push(msg);
session = null;
status_callback(false);
}
private void try_start_session() {
@@ -47,7 +52,9 @@ namespace StudySystemClient {
session = session_factory.start_session();
session.received.connect((msg) => receive_callback(msg));
reconnect_wait_ms = INIT_RECONNECT_WAIT_MS;
status_callback(true);
} catch (Error _) {
status_callback(false);
Thread.usleep(1000 * reconnect_wait_ms);
update_reconnect_wait();
}
@@ -65,7 +72,7 @@ namespace StudySystemClient {
public class SessionFactory {
private const string CA_FILENAME = "/ca.pem";
private const string CERT_FILENAME = "/client.pem";
private const uint TIMEOUT_S = 1;
private const uint TIMEOUT_S = 2;
private InetSocketAddress host;
private TlsCertificate cert;
+4 -21
View File
@@ -1,11 +1,11 @@
.card-container {
background-color: mix(@theme_base_color, @theme_bg_color, 0.7);
background-color: color-mix(in oklab, @theme_base_color,
@theme_bg_color 60%);
padding: 6px;
}
.card {
border: 1px solid alpha(@theme_fg_color, 0.2);
box-shadow: 0 1px 2px alpha(black, 0.15);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
background-color: @theme_bg_color;
padding: 12px 16px;
}
@@ -15,7 +15,7 @@
}
.activity-priority {
color: alpha(@theme_fg_color, 0.6);
color: color-mix(in oklab, @theme_fg_color 60%, @theme_bg_color);
}
/*
@@ -27,20 +27,3 @@
margin-top: -2px;
margin-left: 3px;
}
/*
* Couldn't find a built-in way to get the common OSD overlay style of
* rounding the bottom corners but not the top ones, so doing this
* myself with this CSS rule.
*/
overlay > frame.osd {
border-radius: 0 0 8px 8px;
}
.popdown {
transition: transform 200ms ease-in-out;
transform: translateY(-100px);
}
.popdown.visible {
transform: translateY(0);
}
+10 -12
View File
@@ -12,6 +12,7 @@ start_link(Socket) ->
gen_server:start_link(?MODULE, Socket, []).
init(Socket) ->
ok = ssl:controlling_process(Socket, self()),
ok = ssl:setopts(Socket, [{active, true}]),
process_flag(trap_exit, true),
{ok, #{socket => Socket, transactions => #{}}}.
@@ -38,9 +39,7 @@ handle_info({'EXIT', Pid, Reason},
TransactionId = maps:get(Pid, Transactions),
Response = case Reason of
{response, Value} -> Value;
_ ->
io:format("Error handling request: ~p~n", [Reason]),
{error, serverError}
_ -> {error, serverError}
end,
send(Socket, TransactionId, Response),
{noreply, State#{transactions := maps:remove(Pid, Transactions)}};
@@ -59,25 +58,24 @@ code_change(_OldVsn, State, _Extra) ->
handle_request(Request) ->
timer:kill_after(500),
exit(map_request(Request)).
exit({response, map_request(Request)}).
map_request({ping, 'NULL'}) ->
{response, {ack, 'NULL'}};
{ack, 'NULL'};
map_request({listActivities, 'NULL'}) ->
{activities, Activities} = subject_router:get_activities(),
SortedActivities = lists:reverse(lists:keysort(3, Activities)),
{response, {activities,
[{'Activity', Subject, Type, round(Priority * 100)}
|| {Subject, Type, Priority} <- SortedActivities]}};
{activities,
[{'Activity', Subject, Type, round(Priority * 100)}
|| {Subject, Type, Priority} <- Activities]};
map_request({logSession, {'Session', Subject, Type, Timestamp, Minutes}}) ->
Session = {unicode:characters_to_list(Subject),
Type, Timestamp, Minutes},
case subject_router:log_session(Session) of
ok -> {response, {ack, 'NULL'}};
{error, _Error} -> {response, {error, invalidArguments}}
ok -> {ack, 'NULL'};
{error, _Error} -> {error, invalidArguments}
end;
map_request(_) ->
{response, {error, invalidArguments}}.
{error, invalidArguments}.
send(Socket, TransactionId, Response) ->
{ok, Encoded} = 'StudySystemProtocol':encode(
+1 -2
View File
@@ -41,8 +41,7 @@ handle_info(accept, State = #state{socket = Socket}) ->
handle_info({handshake, TlsSocket}, State) ->
case ssl:handshake(TlsSocket, 5000) of
{ok, ClientSocket} ->
{ok, Pid} = session_sup:start_session(ClientSocket),
ok = ssl:controlling_process(ClientSocket, Pid);
{ok, _Pid} = session_sup:start_session(ClientSocket);
{error, _Reason} ->
ok
end,