Graduately/roadmap.c
pepaskopec-blip 4566336b1b Make the learning path show one next step and a real finish flag.
Every unlocked unit carried the "current" class, so three nodes lit up
identically and none of them pointed anywhere. Mark only the first
unlocked unit that is not finished; the rest stay open but quiet.

The finish node filled its whole circle with chequers, which read as the
placeholder pattern for a missing image rather than as a flag, most of
all in light mode. Give it the plain face the other locked nodes have and
put the chequers in a small badge where they show a padlock.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 15:22:35 +02:00

408 lines
14 KiB
C

#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Roadmap page */
/* ------------------------------------------------------------------ */
/* Draw the "finish line" node: a locked-looking circle with the unit number
* and a small chequered flag. The chequers used to fill the whole circle,
* which read as the placeholder pattern for a missing image rather than as a
* flag, so they are now a badge sitting on a plain node face. */
void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double cell = 6.0; /* chequer square */
const int flag_cols = 3;
const int flag_rows = 2;
const Rgb gray = color_from_hex(app_theme.overlay);
const Rgb fin_dark = color_from_hex(app_theme.finish_dark);
const Rgb fin_light = color_from_hex(app_theme.finish_light);
const Rgb fin_stroke = color_from_hex(app_theme.finish_stroke);
int n = GPOINTER_TO_INT(data);
double cx = width / 2.0;
double cy = height / 2.0;
double r = MIN(width, height) / 2.0 - 2.0;
char buf[8];
PangoLayout *layout;
PangoFontDescription *fd;
PangoRectangle ink;
double num_cy;
double flag_x, flag_y;
int ix, iy;
(void)area;
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
cairo_paint(cr);
cairo_set_operator(cr, CAIRO_OPERATOR_OVER);
cairo_save(cr);
cairo_new_path(cr);
cairo_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_clip(cr);
cairo_rectangle(cr, cx - r, cy - r, 2.0 * r, 2.0 * r);
cairo_set_source_rgb(cr, fin_dark.r, fin_dark.g, fin_dark.b);
cairo_fill(cr);
/* unit number, styled like the other locked nodes */
g_snprintf(buf, sizeof(buf), "%d", n);
layout = pango_cairo_create_layout(cr);
fd = pango_font_description_new();
pango_font_description_set_family(fd, "sans");
pango_font_description_set_weight(fd, PANGO_WEIGHT_ULTRABOLD);
pango_font_description_set_size(fd, 22 * PANGO_SCALE);
pango_layout_set_font_description(layout, fd);
pango_layout_set_text(layout, buf, -1);
pango_layout_get_pixel_extents(layout, &ink, NULL);
cairo_set_source_rgb(cr, gray.r, gray.g, gray.b);
num_cy = cy - 3.0;
cairo_move_to(cr, cx - ink.width / 2.0 - ink.x,
num_cy - ink.height / 2.0 - ink.y);
pango_cairo_show_layout(cr, layout);
/* chequered flag below the number, where locked units show a padlock */
flag_x = cx - (flag_cols * cell) / 2.0;
flag_y = num_cy + ink.height / 2.0 + 6.0;
for (iy = 0; iy < flag_rows; iy++) {
for (ix = 0; ix < flag_cols; ix++) {
const Rgb *c = ((ix + iy) & 1) ? &fin_light : &fin_stroke;
cairo_rectangle(cr, flag_x + ix * cell, flag_y + iy * cell,
cell, cell);
cairo_set_source_rgb(cr, c->r, c->g, c->b);
cairo_fill(cr);
}
}
g_object_unref(layout);
pango_font_description_free(fd);
cairo_restore(cr);
cairo_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, fin_stroke.r, fin_stroke.g, fin_stroke.b);
cairo_set_line_width(cr, 2.0);
cairo_stroke(cr);
}
void road_point(double t, double *ox, double *oy) {
int k = (int)t;
double u = t - (double)k;
double u2, u3;
double p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y;
if (k < 0) { k = 0; u = 0.0; }
if (k >= NUM_UNITS - 1) { k = NUM_UNITS - 2; u = 1.0; }
p1x = road_cx[k]; p1y = road_cy[k];
p2x = road_cx[k + 1]; p2y = road_cy[k + 1];
if (k - 1 >= 0) { p0x = road_cx[k - 1]; p0y = road_cy[k - 1]; }
else { p0x = p1x - (p2x - p1x); p0y = p1y - (p2y - p1y); }
if (k + 2 < NUM_UNITS) { p3x = road_cx[k + 2]; p3y = road_cy[k + 2]; }
else { p3x = p2x + (p2x - p1x); p3y = p2y + (p2y - p1y); }
u2 = u * u;
u3 = u2 * u;
*ox = 0.5 * (2.0 * p1x + (-p0x + p2x) * u
+ (2.0 * p0x - 5.0 * p1x + 4.0 * p2x - p3x) * u2
+ (-p0x + 3.0 * p1x - 3.0 * p2x + p3x) * u3);
*oy = 0.5 * (2.0 * p1y + (-p0y + p2y) * u
+ (2.0 * p0y - 5.0 * p1y + 4.0 * p2y - p3y) * u2
+ (-p0y + 3.0 * p1y - 3.0 * p2y + p3y) * u3);
}
void road_path(cairo_t *cr, double t0, double t1) {
const double steps_per_unit = 48.0;
int n = (int)((t1 - t0) * steps_per_unit);
double x, y;
int s;
if (n < 1)
n = 1;
road_point(t0, &x, &y);
cairo_move_to(cr, x, y);
for (s = 1; s <= n; s++) {
road_point(t0 + (t1 - t0) * (double)s / (double)n, &x, &y);
cairo_line_to(cr, x, y);
}
}
/* Compute the serpentine node layout for the given available width. */
void roadmap_geometry(double avail) {
const double phase = 2.0 * G_PI * 1.7 / (double)(NUM_UNITS - 1);
int rows;
int r, c, i;
for (rows = 1; rows <= NUM_UNITS; rows++) {
int cols = (NUM_UNITS + rows - 1) / rows;
if (2.0 * ROAD_MX + (cols - 1) * PATH_SPAC <= avail + 1.0)
break;
}
if (rows > NUM_UNITS)
rows = NUM_UNITS;
road_rows = rows;
road_cols = (NUM_UNITS + rows - 1) / rows;
if (road_cols < 1)
road_cols = 1;
road_cw = (int)(2.0 * ROAD_MX + (road_cols - 1) * PATH_SPAC);
road_ch = (int)(2.0 * ROAD_MY + (rows - 1) * ROAD_GAP);
for (r = 0; r < rows; r++) {
int base = r * road_cols;
int len = MIN(road_cols, NUM_UNITS - base);
int fwd = (r % 2) == 0;
for (c = 0; c < len; c++) {
i = base + c;
int cc = fwd ? c : (road_cols - 1 - c);
road_cx[i] = ROAD_MX + cc * PATH_SPAC;
road_cy[i] = ROAD_MY + r * ROAD_GAP
+ ROAD_WAVE * sin((double)i * phase);
}
}
}
void road_apply_layout(void) {
int i;
if (!road_fixed)
return;
for (i = 0; i < NUM_UNITS; i++) {
if (!road_nodes[i] || !road_labels[i])
continue;
gtk_fixed_move(GTK_FIXED(road_fixed), road_nodes[i],
(int)(road_cx[i] - NODE_SIZE / 2.0),
(int)(road_cy[i] - NODE_SIZE / 2.0));
gtk_fixed_move(GTK_FIXED(road_fixed), road_labels[i],
(int)(road_cx[i] - (PATH_SPAC - 20.0) / 2.0),
(int)(road_cy[i] + NODE_SIZE / 2.0 + 10.0));
}
gtk_widget_set_size_request(road_fixed, road_cw, road_ch);
gtk_widget_set_size_request(road_rail, road_cw, road_ch);
gtk_fixed_move(GTK_FIXED(road_fixed), road_rail, 0, 0);
gtk_widget_queue_draw(road_rail);
}
void road_relayout(void) {
GtkAdjustment *hadj;
double avail;
if (!road_scroll)
return;
hadj = gtk_scrolled_window_get_hadjustment(
GTK_SCROLLED_WINDOW(road_scroll));
avail = gtk_adjustment_get_page_size(hadj);
if (avail < 1.0)
return;
roadmap_geometry(avail);
road_apply_layout();
}
guint road_idle;
gboolean road_relayout_idle(gpointer data) {
(void)data;
road_idle = 0;
road_relayout();
return G_SOURCE_REMOVE;
}
void road_relayout_later(void) {
if (road_idle == 0)
road_idle = g_idle_add(road_relayout_idle, NULL);
}
void road_adjust_notify(GtkAdjustment *adj, GParamSpec *ps,
gpointer data) {
(void)adj;
(void)ps;
(void)data;
road_relayout_later();
}
void draw_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer user_data) {
const double t_lit = (double)(NUM_UNLOCKED - 1);
const double t_end = (double)(NUM_UNITS - 1);
const Rgb accent = color_from_hex(app_theme.accent);
const Rgb rail = color_from_hex(app_theme.rail);
const Rgb muted = color_from_hex(app_theme.subtext);
(void)area;
(void)width;
(void)height;
(void)user_data;
cairo_set_operator(cr, CAIRO_OPERATOR_CLEAR);
cairo_paint(cr);
cairo_set_operator(cr, CAIRO_OPERATOR_OVER);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
cairo_new_path(cr);
road_path(cr, 0.0, t_end);
cairo_set_line_width(cr, 12);
cairo_set_source_rgb(cr, rail.r, rail.g, rail.b);
cairo_stroke(cr);
if (t_lit > 0.0) {
cairo_new_path(cr);
road_path(cr, 0.0, t_lit);
cairo_set_source_rgb(cr, accent.r, accent.g, accent.b);
cairo_set_line_width(cr, 12);
cairo_stroke(cr);
}
{
double start_t = t_lit > 0.0 ? t_lit : 0.0;
cairo_set_source_rgba(cr, muted.r, muted.g, muted.b, 0.17);
cairo_set_dash(cr, (double[]){1.0, 26.0}, 2, 0.0);
cairo_new_path(cr);
road_path(cr, start_t, t_end);
cairo_stroke(cr);
cairo_set_dash(cr, NULL, 0, 0.0);
}
}
void add_path_node(GtkFixed *fixed, int index) {
int num = index + 1;
UnitCtx *u = &units[index];
gboolean locked = !u->unlocked;
GtkWidget *card;
GtkWidget *name;
char *text;
card = gtk_button_new();
gtk_widget_add_css_class(card, "unit-node");
gtk_widget_set_can_focus(card, FALSE);
gtk_widget_set_sensitive(card, !locked);
gtk_widget_set_size_request(card, (int)NODE_SIZE, (int)NODE_SIZE);
if (index == NUM_UNITS - 1) {
GtkWidget *fin = gtk_drawing_area_new();
gtk_widget_add_css_class(card, "finish");
gtk_widget_add_css_class(card, "locked");
gtk_widget_set_size_request(fin, (int)NODE_SIZE, (int)NODE_SIZE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(fin),
draw_finish_cell,
GINT_TO_POINTER(num), NULL);
gtk_button_set_child(GTK_BUTTON(card), fin);
} else {
GtkWidget *vbox;
GtkWidget *number;
GtkWidget *icon;
/* Unlocked units start out plain; refresh_completion_ui() decides
* which single one is the current step. */
if (locked)
gtk_widget_add_css_class(card, "locked");
vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_halign(vbox, GTK_ALIGN_CENTER);
gtk_widget_set_valign(vbox, GTK_ALIGN_CENTER);
gtk_button_set_child(GTK_BUTTON(card), vbox);
text = g_strdup_printf("%d", num);
number = gtk_label_new(text);
g_free(text);
gtk_widget_add_css_class(number, "unit-number");
gtk_box_append(GTK_BOX(vbox), number);
if (locked) {
icon = icon_area_new(draw_lock_icon,
0.3451, 0.3569, 0.4392, 16);
} else {
icon = icon_area_new(draw_check_icon,
0.1176, 0.1176, 0.1804, 18);
gtk_widget_set_visible(icon, FALSE);
u->node = card;
u->node_done_icon = icon;
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup(u->page), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
}
gtk_box_append(GTK_BOX(vbox), icon);
}
road_nodes[index] = card;
gtk_fixed_put(fixed, card, 0, 0);
name = gtk_label_new(u->title);
gtk_widget_set_size_request(name, (int)(PATH_SPAC - 20.0), -1);
gtk_widget_set_halign(name, GTK_ALIGN_CENTER);
gtk_label_set_justify(GTK_LABEL(name), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(name), TRUE);
gtk_widget_add_css_class(name, "unit-name");
if (locked)
gtk_widget_add_css_class(name, "unit-name-locked");
road_labels[index] = name;
gtk_fixed_put(fixed, name, 0, 0);
}
GtkWidget *build_roadmap_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
int i;
roadmap_geometry(1000.0);
page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_hexpand(page, TRUE);
gtk_widget_set_vexpand(page, TRUE);
gtk_widget_set_margin_start(page, 32);
gtk_widget_set_margin_end(page, 32);
gtk_widget_set_margin_top(page, 24);
gtk_widget_set_margin_bottom(page, 24);
gtk_box_append(GTK_BOX(page),
top_bar("subjects", "roadmap_title", "roadmap_sub"));
scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_set_margin_top(scroll, 14);
gtk_box_append(GTK_BOX(page), scroll);
road_scroll = scroll;
wrap = gtk_center_box_new();
gtk_widget_set_vexpand(wrap, TRUE);
gtk_widget_set_hexpand(wrap, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), wrap);
fixed = gtk_fixed_new();
gtk_widget_set_halign(fixed, GTK_ALIGN_CENTER);
gtk_widget_set_valign(fixed, GTK_ALIGN_CENTER);
gtk_widget_set_size_request(fixed, road_cw, road_ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
road_fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, road_cw, road_ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), draw_rail, NULL, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
road_rail = rail;
for (i = 0; i < NUM_UNITS; i++)
add_path_node(GTK_FIXED(fixed), i);
ha = gtk_scrolled_window_get_hadjustment(GTK_SCROLLED_WINDOW(scroll));
va = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroll));
g_signal_connect(ha, "notify::page-size",
G_CALLBACK(road_adjust_notify), NULL);
g_signal_connect(va, "notify::page-size",
G_CALLBACK(road_adjust_notify), NULL);
road_relayout_later();
return page;
}