Move sources into src/ and runtime files into data/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-09-17 17:00:57 +02:00
commit c2a632c7d6
36 changed files with 0 additions and 0 deletions

1595
src/cetba.c Normal file

File diff suppressed because it is too large Load diff

174
src/czech.c Normal file
View file

@ -0,0 +1,174 @@
#include "maturita.h"
/* ---- Czech language and literature (Český jazyk a literatura) ----- */
#define CZ_NODES 3
#define CZ_BUBBLE SUB_BUBBLE
#define CZ_LABEL_W 200.0
#define CZ_CW 600
#define CZ_CH 470
static const char *cz_keys[CZ_NODES] = {
"Literatura", "Mluvnice", "Maturitní četba",
};
static const char *cz_initials[CZ_NODES] = {
"L", "M", "Č",
};
static GtkWidget *cz_rail;
static double cz_cx[CZ_NODES];
static double cz_cy[CZ_NODES];
static void cz_geometry(void) {
cz_cx[0] = 300.0; cz_cy[0] = 120.0; /* Literatura (top) */
cz_cx[1] = 140.0; cz_cy[1] = 350.0; /* Mluvnice (bottom-left) */
cz_cx[2] = 460.0; cz_cy[2] = 350.0; /* Maturitní četba (bottom-right) */
}
/* Three bubbles joined into a triangle by thick rails. */
static void draw_cz_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const Rgb rail = color_from_hex(app_theme.rail);
const Rgb slate = color_from_hex(app_theme.surface1);
(void)area;
(void)width;
(void)height;
(void)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);
cairo_move_to(cr, cz_cx[0], cz_cy[0]);
cairo_line_to(cr, cz_cx[1], cz_cy[1]);
cairo_line_to(cr, cz_cx[2], cz_cy[2]);
cairo_close_path(cr);
cairo_set_line_width(cr, 14);
cairo_set_source_rgb(cr, rail.r, rail.g, rail.b);
cairo_stroke_preserve(cr);
cairo_set_line_width(cr, 9);
cairo_set_source_rgb(cr, slate.r, slate.g, slate.b);
cairo_stroke(cr);
}
void czech_rail_theme_reset(void) {
if (cz_rail)
gtk_widget_queue_draw(cz_rail);
}
static GtkWidget *cz_make_node(int index, gboolean locked) {
GtkWidget *btn;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "unit-node");
gtk_widget_set_can_focus(btn, FALSE);
gtk_widget_set_size_request(btn, (int)CZ_BUBBLE, (int)CZ_BUBBLE);
if (locked) {
gtk_widget_add_css_class(btn, "locked");
gtk_widget_set_sensitive(btn, FALSE);
gtk_button_set_child(GTK_BUTTON(btn),
icon_area_new(draw_lock_icon,
0.3451, 0.3569, 0.4392, 26));
} else {
GtkWidget *label = gtk_label_new(cz_initials[index]);
gtk_widget_add_css_class(btn, "current");
gtk_widget_add_css_class(label, "unit-number");
gtk_button_set_child(GTK_BUTTON(btn), label);
}
return btn;
}
GtkWidget *build_czechmap_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
cz_geometry();
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", "Český jazyk a literatura",
"czech_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);
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, CZ_CW, CZ_CH);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, CZ_CW, CZ_CH);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), draw_cz_rail,
NULL, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
cz_rail = rail;
for (int i = 0; i < CZ_NODES; i++) {
/* Literatura has no lessons yet; keep the node visible but locked. */
gboolean locked = (i == 0);
GtkWidget *btn = cz_make_node(i, locked);
GtkWidget *name = gtk_label_new(NULL);
if (i == 2) {
g_object_set_data_full(G_OBJECT(btn), "target",
g_strdup("readinglist"), g_free);
g_signal_connect(btn, "clicked", G_CALLBACK(on_nav_clicked), NULL);
} else if (i == 1) {
g_object_set_data_full(G_OBJECT(btn), "target",
g_strdup("mluvnice"), g_free);
g_signal_connect(btn, "clicked", G_CALLBACK(on_nav_clicked), NULL);
}
i18n_bind(name, cz_keys[i], 0);
gtk_widget_set_size_request(name, (int)CZ_LABEL_W, -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");
gtk_widget_add_css_class(name, "cz-label");
if (locked)
gtk_widget_add_css_class(name, "unit-name-locked");
gtk_fixed_put(GTK_FIXED(fixed), btn, 0, 0);
gtk_fixed_put(GTK_FIXED(fixed), name, 0, 0);
gtk_fixed_move(GTK_FIXED(fixed), btn,
(int)(cz_cx[i] - CZ_BUBBLE / 2.0),
(int)(cz_cy[i] - CZ_BUBBLE / 2.0));
gtk_fixed_move(GTK_FIXED(fixed), name,
(int)(cz_cx[i] - CZ_LABEL_W / 2.0),
(int)(cz_cy[i] + CZ_BUBBLE / 2.0 + 10.0));
}
return page;
}

857
src/exercise_common.c Normal file
View file

@ -0,0 +1,857 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Exercise page shell */
/* ------------------------------------------------------------------ */
/* Exercise text would otherwise run the full width of a maximised window,
* which is tiring to read. Keep the body at a comfortable measure and centre
* it once there is room to spare; below that it still fills the page. */
#define EX_BODY_MAX_WIDTH 880
static void ex_body_clamp(GtkAdjustment *adj, GParamSpec *ps, gpointer data) {
GtkWidget *body = data;
double avail = gtk_adjustment_get_page_size(adj);
(void)ps;
if (avail < 1.0)
return;
if (avail < (double)EX_BODY_MAX_WIDTH) {
gtk_widget_set_halign(body, GTK_ALIGN_FILL);
gtk_widget_set_size_request(body, -1, -1);
} else {
gtk_widget_set_halign(body, GTK_ALIGN_CENTER);
gtk_widget_set_size_request(body, EX_BODY_MAX_WIDTH, -1);
}
}
GtkWidget *ex_page_shell(const char *back_target, const char *title,
const char *subtitle, const char *btn_label,
GtkWidget **body_out, GtkWidget **feedback_out,
GtkWidget **btn_out) {
GtkWidget *page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *scroll;
GtkWidget *body;
GtkWidget *bottom;
GtkWidget *feedback;
GtkWidget *btn;
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(back_target, title, subtitle));
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_widget_set_margin_bottom(scroll, 14);
gtk_box_append(GTK_BOX(page), scroll);
body = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
gtk_widget_set_vexpand(body, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), body);
g_signal_connect(gtk_scrolled_window_get_hadjustment(
GTK_SCROLLED_WINDOW(scroll)),
"notify::page-size", G_CALLBACK(ex_body_clamp), body);
*body_out = body;
bottom = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 12);
gtk_widget_set_margin_top(bottom, 4);
gtk_box_append(GTK_BOX(page), bottom);
feedback = gtk_label_new("");
gtk_widget_set_halign(feedback, GTK_ALIGN_START);
gtk_widget_set_valign(feedback, GTK_ALIGN_CENTER);
gtk_widget_set_hexpand(feedback, TRUE);
gtk_box_append(GTK_BOX(bottom), feedback);
*feedback_out = feedback;
btn = gtk_button_new();
i18n_bind(btn, btn_label, 1);
gtk_widget_add_css_class(btn, "btn-primary");
gtk_widget_set_valign(btn, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(bottom), btn);
*btn_out = btn;
return page;
}
/* ------------------------------------------------------------------ */
/* Word-selection (combo box) exercise */
/* ------------------------------------------------------------------ */
/* Build a hidden translation label ("meaning") appended under an item. */
GtkWidget *meaning_add(GtkWidget *body, const char *text) {
GtkWidget *lbl = gtk_label_new(NULL);
i18n_bind(lbl, text, 0);
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_widget_add_css_class(lbl, "meaning");
gtk_label_set_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_set_visible(lbl, FALSE);
gtk_widget_set_margin_top(lbl, 2);
gtk_widget_set_margin_bottom(lbl, 2);
gtk_widget_set_margin_start(lbl, 8);
gtk_box_append(GTK_BOX(body), lbl);
return lbl;
}
GtkWidget *mcq_option_toggle(const char *text, int index,
GtkToggleButton **group_first) {
GtkWidget *tb;
GtkWidget *lab;
char *labeled;
labeled = g_strdup_printf("%c %s", 'A' + index, text);
tb = gtk_toggle_button_new();
lab = gtk_label_new(labeled);
g_free(labeled);
gtk_label_set_wrap(GTK_LABEL(lab), TRUE);
gtk_label_set_wrap_mode(GTK_LABEL(lab), PANGO_WRAP_WORD_CHAR);
gtk_label_set_xalign(GTK_LABEL(lab), 0.0);
gtk_widget_set_halign(lab, GTK_ALIGN_FILL);
gtk_widget_set_hexpand(lab, TRUE);
gtk_button_set_child(GTK_BUTTON(tb), lab);
gtk_widget_add_css_class(tb, "pill");
gtk_widget_add_css_class(tb, "quiz-pill");
gtk_widget_set_hexpand(tb, TRUE);
gtk_widget_set_halign(tb, GTK_ALIGN_FILL);
gtk_toggle_button_set_group(GTK_TOGGLE_BUTTON(tb), *group_first);
if (!*group_first)
*group_first = GTK_TOGGLE_BUTTON(tb);
return tb;
}
GtkWidget *mcq_append_question(GtkWidget *parent, int num, const ChoiceQ *q,
GtkToggleButton **out_toggles) {
GtkWidget *card;
GtkWidget *prompt;
GtkWidget *opts;
GtkToggleButton *first = NULL;
char *qtext;
int o;
card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_add_css_class(card, "quiz-card");
gtk_widget_set_hexpand(card, TRUE);
gtk_box_append(GTK_BOX(parent), card);
qtext = g_strdup_printf("%d.) %s", num, q->prompt);
prompt = gtk_label_new(qtext);
g_free(qtext);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(prompt), TRUE);
gtk_label_set_xalign(GTK_LABEL(prompt), 0.0);
gtk_widget_add_css_class(prompt, "quiz-q");
gtk_box_append(GTK_BOX(card), prompt);
opts = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_add_css_class(opts, "quiz-options");
gtk_box_append(GTK_BOX(card), opts);
for (o = 0; o < q->n_options; o++) {
GtkWidget *tb = mcq_option_toggle(q->options[o], o, &first);
gtk_box_append(GTK_BOX(opts), tb);
out_toggles[o] = GTK_TOGGLE_BUTTON(tb);
}
return card;
}
/* A hidden green label that shows the model German sentence after Check. */
GtkWidget *model_answer_add(GtkWidget *body, const char *german) {
GtkWidget *lbl = gtk_label_new(german);
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_label_set_xalign(GTK_LABEL(lbl), 0.0);
gtk_label_set_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_set_margin_top(lbl, 2);
gtk_widget_set_margin_bottom(lbl, 2);
gtk_widget_set_margin_start(lbl, 8);
gtk_widget_add_css_class(lbl, "u3-answer");
gtk_widget_set_visible(lbl, FALSE);
gtk_box_append(GTK_BOX(body), lbl);
return lbl;
}
void meaning_reveal_all(GtkWidget **labels, int n) {
for (int i = 0; i < n; i++)
if (labels[i])
gtk_widget_set_visible(labels[i], TRUE);
}
ComboListCtx *combo_list_ctx_new(UnitCtx *unit, int ex_num,
GtkWidget *feedback) {
ComboListCtx *ctx = g_new0(ComboListCtx, 1);
ctx->combos = g_array_new(FALSE, FALSE, sizeof(GtkComboBox *));
ctx->answers = g_array_new(FALSE, FALSE, sizeof(const char *));
ctx->trans = g_array_new(FALSE, FALSE, sizeof(GtkWidget *));
ctx->models = g_array_new(FALSE, FALSE, sizeof(GtkWidget *));
ctx->unit = unit;
ctx->ex_num = ex_num;
ctx->feedback = feedback;
return ctx;
}
void combo_list_add(ComboListCtx *ctx, GtkWidget *combo, const char *ans) {
g_array_append_val(ctx->combos, combo);
g_array_append_val(ctx->answers, ans);
}
void combo_list_add_trans(ComboListCtx *ctx, GtkWidget *label) {
g_array_append_val(ctx->trans, label);
}
GtkWidget *make_word_combo(const char **words, int n) {
GtkWidget *combo = gtk_combo_box_text_new();
int *order = g_new0(int, n);
for (int i = 0; i < n; i++)
order[i] = i;
shuffle_indices(order, n);
for (int i = 0; i < n; i++)
gtk_combo_box_text_append(GTK_COMBO_BOX_TEXT(combo), NULL,
words[order[i]]);
gtk_combo_box_set_active(GTK_COMBO_BOX(combo), -1);
g_free(order);
return combo;
}
gboolean answer_accepts(const char *sel_norm, const char *ans) {
gchar **parts = g_strsplit(ans, "|", -1);
gboolean ok = FALSE;
for (int i = 0; parts[i] != NULL; i++) {
gchar *a = normalize_answer(parts[i]);
if (g_strcmp0(sel_norm, a) == 0) {
ok = TRUE;
g_free(a);
break;
}
g_free(a);
}
g_strfreev(parts);
return ok;
}
/* True when the typed answer matches the beginning of an accepted answer but
* is missing one or more trailing words (e.g. "jmenovat" for "jmenovat se").
* Used to warn about an incomplete answer before counting it wrong. */
gboolean answer_is_incomplete(const char *sel_norm, const char *ans) {
gchar **parts = g_strsplit(ans, "|", -1);
gboolean incomplete = FALSE;
for (int i = 0; parts[i] != NULL && !incomplete; i++) {
gchar *a = normalize_answer(parts[i]);
gchar **sel_w = g_strsplit(sel_norm, " ", -1);
gchar **ans_w = g_strsplit(a, " ", -1);
int nsel = g_strv_length(sel_w);
int nans = g_strv_length(ans_w);
int k;
if (sel_norm[0] && nsel > 0 && nsel < nans) {
incomplete = TRUE;
for (k = 0; k < nsel; k++) {
if (g_strcmp0(sel_w[k], ans_w[k]) != 0) {
incomplete = FALSE;
break;
}
}
}
g_strfreev(sel_w);
g_strfreev(ans_w);
g_free(a);
}
g_strfreev(parts);
return incomplete;
}
void combo_list_check(GtkButton *button, gpointer data) {
ComboListCtx *ctx = data;
guint total = ctx->combos->len;
guint ok = 0;
(void)button;
for (guint i = 0; i < total; i++) {
GtkComboBox *combo = g_array_index(ctx->combos, GtkComboBox *, i);
const char *ans = g_array_index(ctx->answers, const char *, i);
gchar *sel = gtk_combo_box_text_get_active_text(GTK_COMBO_BOX_TEXT(combo));
gboolean good = FALSE;
if (sel) {
gchar *t = normalize_answer(sel);
good = answer_accepts(t, ans);
g_free(t);
g_free(sel);
}
answer_mark(GTK_WIDGET(combo), good);
if (good)
ok++;
}
if (ctx->trans->len > 0) {
for (guint i = 0; i < ctx->trans->len; i++) {
GtkWidget *lbl = g_array_index(ctx->trans, GtkWidget *, i);
if (lbl)
gtk_widget_set_visible(lbl, TRUE);
}
}
if (ctx->models->len > 0) {
for (guint i = 0; i < ctx->models->len; i++) {
GtkWidget *lbl = g_array_index(ctx->models, GtkWidget *, i);
if (lbl)
gtk_widget_set_visible(lbl, TRUE);
}
}
if (ok == total) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
mark_done(ctx->unit, ctx->ex_num);
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_retry"));
}
}
/* ------------------------------------------------------------------ */
/* Word-arrangement (chips) exercise */
/* ------------------------------------------------------------------ */
void sent_rebuild(SentBuilder *sb) {
for (int i = 0; i < sb->n_words; i++) {
GtkWidget *parent = gtk_widget_get_parent(sb->chips[i]);
if (parent != NULL && GTK_IS_FLOW_BOX_CHILD(parent))
gtk_flow_box_child_set_child(GTK_FLOW_BOX_CHILD(parent), NULL);
}
flow_clear(GTK_FLOW_BOX(sb->pool));
flow_clear(GTK_FLOW_BOX(sb->target));
for (int i = 0; i < sb->placed_count; i++)
gtk_flow_box_insert(GTK_FLOW_BOX(sb->target),
sb->chips[sb->placed[i]], -1);
for (int i = 0; i < sb->n_words; i++) {
int w = sb->pool_order[i];
gboolean in_target = FALSE;
for (int j = 0; j < sb->placed_count; j++) {
if (sb->placed[j] == w) {
in_target = TRUE;
break;
}
}
if (!in_target)
gtk_flow_box_insert(GTK_FLOW_BOX(sb->pool), sb->chips[w], -1);
}
}
GdkContentProvider *asm_drag_prepare(GtkDragSource *source,
double x, double y,
gpointer data) {
ChipRef *ref = data;
(void)source;
(void)x;
(void)y;
return gdk_content_provider_new_typed(G_TYPE_INT, ref->idx);
}
gboolean asm_rebuild_idle(gpointer data) {
sent_rebuild(data);
return G_SOURCE_REMOVE;
}
void asm_drag_end(GtkDragSource *source, GdkDrag *drag,
gboolean delete_data, gpointer data) {
ChipRef *ref = data;
(void)source;
(void)drag;
(void)delete_data;
g_idle_add(asm_rebuild_idle, ref->sb);
}
gboolean asm_drop_to_sentence(GtkDropTarget *target, const GValue *value,
double x, double y, gpointer data) {
SentBuilder *sb = data;
int idx;
int pos = -1;
(void)target;
(void)x;
(void)y;
if (!G_VALUE_HOLDS_INT(value))
return FALSE;
idx = g_value_get_int(value);
for (int i = 0; i < sb->placed_count; i++) {
if (sb->placed[i] == idx) {
pos = i;
break;
}
}
if (pos >= 0) {
for (int i = pos; i < sb->placed_count - 1; i++)
sb->placed[i] = sb->placed[i + 1];
sb->placed_count--;
}
sb->placed[sb->placed_count++] = idx;
return TRUE;
}
gboolean asm_drop_to_pool(GtkDropTarget *target, const GValue *value,
double x, double y, gpointer data) {
SentBuilder *sb = data;
int idx;
int pos = -1;
(void)target;
(void)x;
(void)y;
if (!G_VALUE_HOLDS_INT(value))
return FALSE;
idx = g_value_get_int(value);
for (int i = 0; i < sb->placed_count; i++) {
if (sb->placed[i] == idx) {
pos = i;
break;
}
}
if (pos < 0)
return FALSE;
for (int i = pos; i < sb->placed_count - 1; i++)
sb->placed[i] = sb->placed[i + 1];
sb->placed_count--;
return TRUE;
}
/* ------------------------------------------------------------------ */
/* Click-to-move with a fly ("glide") animation */
/* ------------------------------------------------------------------ */
void asm_finish_fly(FlyCtx *fc) {
gtk_widget_remove_css_class(fc->chip, "ghost-host");
gtk_fixed_remove(GTK_FIXED(fc->stage), fc->ghost);
if (fc->flying)
*fc->flying = FALSE;
g_free(fc);
}
gboolean asm_fly_tick(gpointer data) {
FlyCtx *fc = data;
gint64 now = g_get_monotonic_time() / 1000;
if (fc->t0 < 0) {
double nx = 0.0, ny = 0.0;
gboolean ok;
fc->wait++;
ok = gtk_widget_translate_coordinates(fc->chip, fc->stage,
0, 0, &nx, &ny);
if (fc->wait < 2)
return G_SOURCE_CONTINUE;
if (ok && fc->wait < 14 &&
fabs(nx - fc->sx) + fabs(ny - fc->sy) < 0.5)
return G_SOURCE_CONTINUE; /* still waiting for relayout */
if (ok) {
fc->tx = nx;
fc->ty = ny;
} else {
fc->tx = fc->sx;
fc->ty = fc->sy;
}
fc->t0 = now;
return G_SOURCE_CONTINUE;
}
{
double t = (double)(now - fc->t0) / 240.0;
double e, x, y;
if (t > 1.0)
t = 1.0;
e = 1.0 - pow(1.0 - t, 3.0); /* ease-out cubic */
x = fc->sx + (fc->tx - fc->sx) * e;
y = fc->sy + (fc->ty - fc->sy) * e;
gtk_fixed_move(GTK_FIXED(fc->stage), fc->ghost, (int)x, (int)y);
if (t >= 1.0) {
asm_finish_fly(fc);
return G_SOURCE_REMOVE;
}
}
return G_SOURCE_CONTINUE;
}
/* Start a fly animation: hide `chip` in place and animate a ghost from
* (sx, sy) to the chip's new location. */
void asm_begin_fly(GtkWidget *chip, GtkWidget *stage,
gboolean *flying, double sx, double sy) {
GtkWidget *child = gtk_button_get_child(GTK_BUTTON(chip));
const char *txt = (child && GTK_IS_LABEL(child))
? gtk_label_get_text(GTK_LABEL(child)) : NULL;
GtkWidget *ghost = gtk_button_new_with_label(txt ? txt : "");
FlyCtx *fc;
gtk_widget_add_css_class(ghost, "chip");
gtk_widget_set_sensitive(ghost, FALSE);
gtk_widget_set_can_focus(ghost, FALSE);
gtk_fixed_put(GTK_FIXED(stage), ghost, (int)sx, (int)sy);
fc = g_new0(FlyCtx, 1);
fc->chip = chip;
fc->ghost = ghost;
fc->stage = stage;
fc->flying = flying;
fc->sx = sx;
fc->sy = sy;
fc->t0 = -1;
if (flying)
*flying = TRUE;
gtk_widget_add_css_class(chip, "ghost-host");
g_timeout_add(16, asm_fly_tick, fc);
}
void asm_move_by_click(GtkButton *button, gpointer data) {
ChipRef *ref = data;
SentBuilder *sb = ref->sb;
int idx = ref->idx;
int pos = -1;
double sx = 0.0, sy = 0.0;
GtkWidget *chip = GTK_WIDGET(button);
(void)button;
if (sb->flying)
return;
for (int i = 0; i < sb->placed_count; i++) {
if (sb->placed[i] == idx) {
pos = i;
break;
}
}
if (sb->stage &&
gtk_widget_translate_coordinates(chip, sb->stage, 0, 0, &sx, &sy)) {
if (pos >= 0) {
for (int i = pos; i < sb->placed_count - 1; i++)
sb->placed[i] = sb->placed[i + 1];
sb->placed_count--;
} else {
sb->placed[sb->placed_count++] = idx;
}
sent_rebuild(sb);
asm_begin_fly(chip, sb->stage, &sb->flying, sx, sy);
} else {
/* No usable animation layer yet – just move instantly. */
if (pos >= 0) {
for (int i = pos; i < sb->placed_count - 1; i++)
sb->placed[i] = sb->placed[i + 1];
sb->placed_count--;
} else {
sb->placed[sb->placed_count++] = idx;
}
sent_rebuild(sb);
}
}
void assembly_check(GtkButton *button, gpointer data) {
AsmCtx *ctx = data;
int ok = 0;
(void)button;
for (int i = 0; i < ctx->n; i++) {
SentBuilder *sb = ctx->sbs[i];
gboolean good = (sb->placed_count == sb->n_words);
if (good) {
for (int j = 0; j < sb->placed_count; j++) {
if (sb->placed[j] != j) {
good = FALSE;
break;
}
}
}
if (good) {
ok++;
gtk_widget_remove_css_class(sb->group, "wrong");
gtk_widget_add_css_class(sb->group, "ok");
} else {
gtk_widget_remove_css_class(sb->group, "ok");
gtk_widget_add_css_class(sb->group, "wrong");
}
}
for (int i = 0; i < ctx->n; i++) {
if (ctx->sbs[i]->trans)
gtk_widget_set_visible(ctx->sbs[i]->trans, TRUE);
}
if (ok == ctx->n) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
mark_done(ctx->unit, ctx->ex_num);
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_sentences"));
}
}
GtkWidget *build_assembly(UnitCtx *unit, const char *title,
const char *subtitle, int ex_num,
const AssemblyItem *items,
const char **meanings, int n) {
GtkWidget *body, *feedback, *check;
GtkWidget *page = ex_page_shell(unit->page, title, subtitle, "check",
&body, &feedback, &check);
AsmCtx *ctx = g_new0(AsmCtx, 1);
ctx->sbs = g_new0(SentBuilder *, n);
ctx->n = n;
ctx->unit = unit;
ctx->ex_num = ex_num;
ctx->feedback = feedback;
for (int s = 0; s < n; s++) {
const AssemblyItem *item = &items[s];
SentBuilder *sb = g_new0(SentBuilder, 1);
GtkWidget *holder;
GtkWidget *group;
GtkWidget *target;
GtkWidget *pool;
GtkWidget *stage;
sb->n_words = item->n;
ctx->sbs[s] = sb;
if (item->prompt) {
GtkWidget *prompt = gtk_label_new(item->prompt);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_widget_add_css_class(prompt, "chain");
gtk_box_append(GTK_BOX(body), prompt);
}
holder = gtk_overlay_new();
gtk_widget_set_hexpand(holder, TRUE);
group = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_add_css_class(group, "sent-group");
sb->group = group;
gtk_overlay_set_child(GTK_OVERLAY(holder), group);
target = gtk_flow_box_new();
gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(target), GTK_SELECTION_NONE);
gtk_flow_box_set_min_children_per_line(GTK_FLOW_BOX(target), 2);
gtk_widget_set_size_request(target, -1, 44);
sb->target = target;
gtk_box_append(GTK_BOX(group), target);
pool = gtk_flow_box_new();
gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(pool), GTK_SELECTION_NONE);
gtk_flow_box_set_min_children_per_line(GTK_FLOW_BOX(pool), 2);
gtk_widget_set_size_request(pool, -1, 44);
sb->pool = pool;
gtk_box_append(GTK_BOX(group), pool);
stage = gtk_fixed_new();
gtk_widget_set_halign(stage, GTK_ALIGN_FILL);
gtk_widget_set_valign(stage, GTK_ALIGN_FILL);
gtk_widget_set_can_target(stage, FALSE);
gtk_overlay_add_overlay(GTK_OVERLAY(holder), stage);
sb->stage = stage;
{
GtkDropTarget *dt = gtk_drop_target_new(G_TYPE_INT, GDK_ACTION_COPY);
GtkDropTarget *dp = gtk_drop_target_new(G_TYPE_INT, GDK_ACTION_COPY);
g_signal_connect(dt, "drop", G_CALLBACK(asm_drop_to_sentence), sb);
gtk_widget_add_controller(target, GTK_EVENT_CONTROLLER(dt));
g_signal_connect(dp, "drop", G_CALLBACK(asm_drop_to_pool), sb);
gtk_widget_add_controller(pool, GTK_EVENT_CONTROLLER(dp));
}
for (int w = 0; w < item->n; w++) {
GtkWidget *chip = gtk_button_new_with_label(item->words[w]);
GtkDragSource *src = gtk_drag_source_new();
ChipRef *ref = g_new(ChipRef, 1);
gtk_widget_add_css_class(chip, "chip");
g_object_ref(chip);
sb->chips[w] = chip;
ref->sb = sb;
ref->idx = w;
gtk_drag_source_set_actions(src, GDK_ACTION_COPY);
g_signal_connect(src, "prepare", G_CALLBACK(asm_drag_prepare), ref);
g_signal_connect(src, "drag-end", G_CALLBACK(asm_drag_end), ref);
g_signal_connect(chip, "clicked", G_CALLBACK(asm_move_by_click), ref);
gtk_widget_add_controller(chip, GTK_EVENT_CONTROLLER(src));
}
{
for (int w = 0; w < item->n; w++)
sb->pool_order[w] = w;
shuffle_indices(sb->pool_order, item->n);
for (int w = 0; w < item->n; w++)
gtk_flow_box_insert(GTK_FLOW_BOX(pool),
sb->chips[sb->pool_order[w]], -1);
}
gtk_box_append(GTK_BOX(body), holder);
if (meanings[s])
sb->trans = meaning_add(body, meanings[s]);
}
g_signal_connect(check, "clicked", G_CALLBACK(assembly_check), ctx);
return page;
}
/* ------------------------------------------------------------------ */
/* Exercise 1: Dialog (word selection) */
/* ------------------------------------------------------------------ */
const DialogRow ex1_d1[] = {
{"A:", "Hallo! Ich ", "bin|heiße", " Anna."},
{"B:", "Hallo! Ich ", "bin|heiße", " Peter."},
{"A:", "Woher ", "kommst", " du?"},
{"B:", "Ich komme ", "aus", " Tschechien."},
};
const DialogRow ex1_d2[] = {
{"A:", "Guten ", "Morgen", "!"},
{"B:", "Wie ", "geht", " es dir?"},
{"A:", "Mir geht es gut, ", "danke", "."},
};
const DialogRow ex1_d3[] = {
{"A:", "Ich muss gehen. Auf ", "Wiedersehen", "!"},
{"B:", "", "Bis", " bald!"},
{"A:", "", "Tschüss", "!"},
};
const Dialogue ex1_dialogues[] = {
{"Sich vorstellen", ex1_d1, G_N_ELEMENTS(ex1_d1)},
{"Begrüßung", ex1_d2, G_N_ELEMENTS(ex1_d2)},
{"Verabschiedung", ex1_d3, G_N_ELEMENTS(ex1_d3)},
};
const char *ex1_pool[] = {
"bin", "heiße", "kommst", "aus", "Morgen",
"geht", "danke", "Wiedersehen", "Bis", "Tschüss",
};
/* One Czech meaning per dialog line, in the same order as the rows. */
const char *ex1_meaning[] = {
"Ahoj! Jmenuji se Anna.",
"Ahoj! Jmenuji se Petr.",
"Odkud jsi?",
"Pocházím z Česka.",
"Dobré ráno!",
"Jak se máš?",
"Mám se dobře, děkuji.",
"Musím jít. Na shledanou!",
"Brzy na viděnou!",
"Čau!",
};
GtkWidget *build_ex1(UnitCtx *unit) {
GtkWidget *body, *feedback, *check;
GtkWidget *page = ex_page_shell(unit->page, "Dialog",
"sub_dialog",
"check", &body, &feedback, &check);
ComboListCtx *ctx = combo_list_ctx_new(unit, 1, feedback);
int ri = 0;
for (guint d = 0; d < G_N_ELEMENTS(ex1_dialogues); d++) {
const Dialogue *diag = &ex1_dialogues[d];
GtkWidget *name = gtk_label_new(diag->name);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_widget_add_css_class(name, "ex-sub");
gtk_widget_set_margin_top(name, d > 0 ? 10 : 0);
gtk_box_append(GTK_BOX(body), name);
for (int r = 0; r < diag->n; r++) {
const DialogRow *row = &diag->rows[r];
GtkWidget *line = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6);
GtkWidget *combo;
gtk_widget_set_halign(line, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(body), line);
if (row->speaker && row->speaker[0]) {
GtkWidget *spk = gtk_label_new(row->speaker);
gtk_widget_add_css_class(spk, "dlg-speaker");
gtk_widget_set_valign(spk, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(line), spk);
}
if (row->before && row->before[0]) {
GtkWidget *lb = gtk_label_new(row->before);
gtk_widget_set_valign(lb, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(lb, "ex-prompt");
gtk_box_append(GTK_BOX(line), lb);
}
combo = make_word_combo(ex1_pool, G_N_ELEMENTS(ex1_pool));
gtk_widget_set_size_request(combo, 130, -1);
gtk_widget_set_valign(combo, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(line), combo);
combo_list_add(ctx, combo, row->answer);
if (row->after && row->after[0]) {
GtkWidget *la = gtk_label_new(row->after);
gtk_widget_set_valign(la, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(la, "ex-prompt");
gtk_box_append(GTK_BOX(line), la);
}
if (ri < (int)G_N_ELEMENTS(ex1_meaning))
combo_list_add_trans(ctx, meaning_add(body, ex1_meaning[ri]));
ri++;
}
}
g_signal_connect(check, "clicked", G_CALLBACK(combo_list_check), ctx);
return page;
}
/* ------------------------------------------------------------------ */
/* Exercise 2: Sätze bilden (word arrangement) */
/* ------------------------------------------------------------------ */
const AssemblyItem ex2_items[] = {
{NULL, {"Ich", "heiße", "Anna", "."}, 4},
{NULL, {"Woher", "kommst", "du", "?"}, 4},
{NULL, {"Ich", "komme", "aus", "Tschechien", "."}, 5},
{NULL, {"Wie", "geht", "es", "dir", "?"}, 5},
};
const char *ex2_meaning[] = {
"Jmenuji se Anna.",
"Odkud jsi?",
"Pocházím z Česka.",
"Jak se máš?",
};

1360
src/exercise_u1.c Normal file

File diff suppressed because it is too large Load diff

1540
src/exercise_u2.c Normal file

File diff suppressed because it is too large Load diff

1547
src/exercise_u3.c Normal file

File diff suppressed because it is too large Load diff

83
src/globals.c Normal file
View file

@ -0,0 +1,83 @@
#include "maturita.h"
GtkStack *main_stack;
GtkWindow *main_window;
GtkWidget *welcome_heading;
GtkCssProvider *theme_provider;
ThemeId app_theme_id = THEME_CATPPUCCIN;
ColorMode app_color_mode = MODE_DARK;
UiLang app_lang = LANG_CS;
ThemePalette app_theme;
double app_ui_scale = 1.0;
const char *theme_names[THEME_COUNT] = {
"Catppuccin",
"Nord",
"Dracula",
"Rose Pine",
"Ocean",
"Gruvbox",
"Solarized",
"Everforest",
"Monokai",
"One Dark",
};
GtkWidget *theme_swatch_areas[THEME_COUNT];
UnitCtx units[NUM_UNITS] = {0};
const char *const u1_ex_names[MAX_UNIT_EX + 1] = {
NULL,
"Dialog", "Sätze bilden", "Was ist richtig?", "Freie Antwort",
"Zahlen", "Wie viel?", "Zahlenreihe", "Verb einsetzen",
"Wer? Wie? Wo?", "Wörter trennen", "Grußformen",
"Was macht er/sie?", "Länder",
};
const char *const u2_ex_names[MAX_UNIT_EX + 1] = {
NULL,
"Verben konjugieren", "aus oder in", "Fragewörter", "sprechen",
"Nationalitäten", "Woher?", "Länder schreiben", "Verben einsetzen",
"Freie Antwort", "Euro", "Wörter suchen", "Was ist richtig?",
"Ordne zu", "Lückentext", "Verbinde", "Zahlen",
"Steckbrief", "Berufe", "Nationalität",
};
const char *const u3_ex_names[MAX_UNIT_EX + 1] = {
NULL,
"Familienpaare", "mein oder dein", "ein / kein", "Lückentext",
"Marcos Familie", "Sortieren", "Possessivtabelle", "Akkusativ",
"kein / nicht", "Sätze bauen", "Was siehst du?", "Wem gehört das?",
"Es gibt …", "Beschreiben", "Wochenende",
};
GtkWidget *road_scroll;
GtkWidget *road_fixed;
GtkWidget *road_rail;
GtkWidget *road_nodes[NUM_UNITS];
GtkWidget *road_labels[NUM_UNITS];
double road_cx[NUM_UNITS];
double road_cy[NUM_UNITS];
int road_cols = NUM_UNITS;
int road_rows = 1;
int road_cw = (int)(2.0 * ROAD_MX + (NUM_UNITS - 1) * PATH_SPAC);
int road_ch = (int)(2.0 * ROAD_MY);

906
src/hw.c Normal file
View file

@ -0,0 +1,906 @@
#include "maturita.h"
/* ---- Technical equipment (Technické vybavení) --------------------- */
NetLesson hw_lessons[HW_LESSONS] = {
{ .n_slides = HW_SLIDES, .unit_page = "hwunit1", .ex_page = "hwex1" },
{ .n_slides = HW2_SLIDES, .unit_page = "hwunit2", .ex_page = "hwex2" },
{ .n_slides = HW3_SLIDES, .unit_page = "hwunit3", .ex_page = "hwex3" },
};
void hw_rail_theme_reset(void);
void refresh_hw_completion_ui(void);
GtkWidget *hw_scroll;
GtkWidget *hw_fixed;
GtkWidget *hw_rail;
GtkWidget *hw_nodes[HW_UNITS];
GtkWidget *hw_labels[HW_UNITS];
double hw_cx[HW_UNITS];
double hw_cy[HW_UNITS];
int hw_cols = 1;
int hw_rows = 1;
int hw_cw = (int)(2.0 * ROAD_MX + (HW_UNITS - 1) * PATH_SPAC);
int hw_ch = (int)(2.0 * ROAD_MY);
guint hw_idle;
double hw_last_avail = -1.0;
int hw_last_cols = -1;
int hw_last_rows = -1;
int hw_last_cw = -1;
int hw_last_ch = -1;
cairo_surface_t *hw_rail_cache;
int hw_cache_w;
int hw_cache_h;
void hw_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 >= HW_UNITS - 1) { k = HW_UNITS - 2; u = 1.0; }
p1x = hw_cx[k]; p1y = hw_cy[k];
p2x = hw_cx[k + 1]; p2y = hw_cy[k + 1];
if (k - 1 >= 0) { p0x = hw_cx[k - 1]; p0y = hw_cy[k - 1]; }
else { p0x = p1x - (p2x - p1x); p0y = p1y - (p2y - p1y); }
if (k + 2 < HW_UNITS) { p3x = hw_cx[k + 2]; p3y = hw_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 hw_path(cairo_t *cr, double t0, double t1) {
const double steps_per_unit = 24.0;
int n = (int)((t1 - t0) * steps_per_unit);
double x, y;
int s;
if (n < 1)
n = 1;
hw_point(t0, &x, &y);
cairo_move_to(cr, x, y);
for (s = 1; s <= n; s++) {
hw_point(t0 + (t1 - t0) * (double)s / (double)n, &x, &y);
cairo_line_to(cr, x, y);
}
}
void hw_geometry(double avail) {
const double phase = 2.0 * G_PI * 1.7 / (double)(HW_UNITS - 1);
int rows;
int r, c, i;
for (rows = 1; rows <= HW_UNITS; rows++) {
int cols = (HW_UNITS + rows - 1) / rows;
if (2.0 * ROAD_MX + (cols - 1) * PATH_SPAC <= avail + 1.0)
break;
}
if (rows > HW_UNITS)
rows = HW_UNITS;
hw_rows = rows;
hw_cols = (HW_UNITS + rows - 1) / rows;
if (hw_cols < 1)
hw_cols = 1;
hw_cw = (int)(2.0 * ROAD_MX + (hw_cols - 1) * PATH_SPAC);
hw_ch = (int)(2.0 * ROAD_MY + (rows - 1) * ROAD_GAP);
for (r = 0; r < rows; r++) {
int base = r * hw_cols;
int len = MIN(hw_cols, HW_UNITS - base);
int fwd = (r % 2) == 0;
for (c = 0; c < len; c++) {
i = base + c;
int cc = fwd ? c : (hw_cols - 1 - c);
hw_cx[i] = ROAD_MX + cc * PATH_SPAC;
hw_cy[i] = ROAD_MY + r * ROAD_GAP
+ ROAD_WAVE * sin((double)i * phase);
}
}
}
void hw_apply_layout(void) {
if (!hw_fixed)
return;
if (hw_cols == hw_last_cols && hw_rows == hw_last_rows &&
hw_cw == hw_last_cw && hw_ch == hw_last_ch)
return;
hw_last_cols = hw_cols;
hw_last_rows = hw_rows;
hw_last_cw = hw_cw;
hw_last_ch = hw_ch;
if (hw_rail_cache) {
cairo_surface_destroy(hw_rail_cache);
hw_rail_cache = NULL;
}
for (int i = 0; i < HW_UNITS; i++) {
if (!hw_nodes[i] || !hw_labels[i])
continue;
gtk_fixed_move(GTK_FIXED(hw_fixed), hw_nodes[i],
(int)(hw_cx[i] - NODE_SIZE / 2.0),
(int)(hw_cy[i] - NODE_SIZE / 2.0));
gtk_fixed_move(GTK_FIXED(hw_fixed), hw_labels[i],
(int)(hw_cx[i] - (PATH_SPAC - 20.0) / 2.0),
(int)(hw_cy[i] + NODE_SIZE / 2.0 + 10.0));
}
gtk_widget_set_size_request(hw_fixed, hw_cw, hw_ch);
gtk_widget_set_size_request(hw_rail, hw_cw, hw_ch);
gtk_fixed_move(GTK_FIXED(hw_fixed), hw_rail, 0, 0);
gtk_widget_queue_draw(hw_rail);
}
void hw_relayout(void) {
GtkAdjustment *hadj;
double avail;
if (!hw_scroll)
return;
hadj = gtk_scrolled_window_get_hadjustment(
GTK_SCROLLED_WINDOW(hw_scroll));
avail = gtk_adjustment_get_page_size(hadj);
if (avail < 1.0)
return;
if (fabs(avail - hw_last_avail) < 1.0)
return;
hw_last_avail = avail;
hw_geometry(avail);
hw_apply_layout();
}
gboolean hw_relayout_idle(gpointer data) {
(void)data;
hw_idle = 0;
hw_relayout();
return G_SOURCE_REMOVE;
}
void hw_relayout_later(void) {
if (hw_idle == 0)
hw_idle = g_idle_add(hw_relayout_idle, NULL);
}
void hw_adjust_notify(GtkAdjustment *adj, GParamSpec *ps,
gpointer data) {
(void)adj;
(void)ps;
(void)data;
hw_relayout_later();
}
void hw_render_rail_to(cairo_t *cr) {
const double t_end = (double)(HW_UNITS - 1);
const Rgb rail = color_from_hex(app_theme.rail);
const Rgb muted = color_from_hex(app_theme.subtext);
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);
hw_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);
cairo_set_source_rgba(cr, muted.r, muted.g, muted.b, 0.17);
cairo_set_dash(cr, (double[]){2.0, 40.0}, 2, 0.0);
cairo_new_path(cr);
hw_path(cr, 0.0, t_end);
cairo_stroke(cr);
cairo_set_dash(cr, NULL, 0, 0.0);
}
void hw_draw_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
(void)area;
(void)width;
(void)height;
(void)data;
if (!hw_rail_cache || hw_cache_w != hw_cw || hw_cache_h != hw_ch) {
cairo_t *rcr;
if (hw_rail_cache)
cairo_surface_destroy(hw_rail_cache);
hw_cache_w = hw_cw;
hw_cache_h = hw_ch;
if (hw_cache_w < 1)
hw_cache_w = 1;
if (hw_cache_h < 1)
hw_cache_h = 1;
hw_rail_cache = cairo_image_surface_create(CAIRO_FORMAT_ARGB32,
hw_cache_w, hw_cache_h);
rcr = cairo_create(hw_rail_cache);
hw_render_rail_to(rcr);
cairo_destroy(rcr);
}
cairo_set_source_surface(cr, hw_rail_cache, 0, 0);
cairo_paint(cr);
}
void hw_rail_theme_reset(void) {
if (hw_rail_cache) {
cairo_surface_destroy(hw_rail_cache);
hw_rail_cache = NULL;
}
if (hw_rail)
gtk_widget_queue_draw(hw_rail);
}
void hw_add_node(GtkFixed *fixed, int index) {
int num = index + 1;
gboolean locked = index >= HW_LESSONS;
GtkWidget *card;
GtkWidget *vbox;
GtkWidget *number;
GtkWidget *icon;
GtkWidget *name;
char *text;
static const char *unit_keys[HW_LESSONS] = {
"hw_unit1", "hw_unit2", "hw_unit3"
};
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 (locked)
gtk_widget_add_css_class(card, "locked");
else
gtk_widget_add_css_class(card, "current");
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);
gtk_box_append(GTK_BOX(vbox), icon);
} else {
icon = icon_area_new(draw_check_icon, 0.1176, 0.1176, 0.1804, 18);
gtk_widget_set_visible(icon, FALSE);
gtk_box_append(GTK_BOX(vbox), icon);
hw_lessons[index].done_icon = icon;
g_signal_connect(card, "clicked", G_CALLBACK(net_open_unit),
&hw_lessons[index]);
}
hw_nodes[index] = card;
gtk_fixed_put(fixed, card, 0, 0);
name = gtk_label_new(NULL);
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 (index < HW_LESSONS) {
i18n_bind(name, unit_keys[index], 0);
} else {
gtk_widget_add_css_class(name, "unit-name-locked");
}
hw_labels[index] = name;
gtk_fixed_put(fixed, name, 0, 0);
}
GtkWidget *build_hwmap_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
hw_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", "Technické vybavení", "hw_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);
hw_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, hw_cw, hw_ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
hw_fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, hw_cw, hw_ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), hw_draw_rail,
NULL, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
hw_rail = rail;
for (int i = 0; i < HW_UNITS; i++)
hw_add_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(hw_adjust_notify), NULL);
g_signal_connect(va, "notify::page-size",
G_CALLBACK(hw_adjust_notify), NULL);
hw_apply_layout();
hw_relayout_later();
refresh_hw_completion_ui();
return page;
}
void hw_slide_apply(NetLesson *L);
void hw_slide_prev(GtkButton *button, gpointer data);
void hw_slide_next(GtkButton *button, gpointer data);
/* ---- Unit 1: Architektura počítače -------------------------------- */
static GtkWidget *hw_note_host;
static int hw_note_last_w = -1;
static void hw_rescale_notes_at(int w) {
int body, head, kick;
if (w < 160)
w = 160;
body = w / 46;
if (body < 14) body = 14;
if (body > 26) body = 26;
head = body + 14;
if (head > 46) head = 46;
kick = body - 3;
if (kick < 11) kick = 11;
for (int i = 0; i < HW_LESSONS; i++)
net_rescale_lesson_notes(&hw_lessons[i], body, head, kick);
}
static gboolean hw_note_tick(GtkWidget *w, GdkFrameClock *clock,
gpointer data) {
int width = gtk_widget_get_allocated_width(w);
(void)clock;
(void)data;
if (width != hw_note_last_w) {
hw_note_last_w = width;
hw_rescale_notes_at(width);
}
return G_SOURCE_CONTINUE;
}
static GtkWidget *build_hw_unit_page(NetLesson *L, const char *title_key,
const char *sub_key,
const NetSlide *slides, guint n_slides) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *nav;
net_lesson_notes_ensure(L);
net_notes_target = L;
L->n_slides = n_slides;
page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_margin_start(page, 28);
gtk_widget_set_margin_end(page, 28);
gtk_widget_set_margin_top(page, 20);
gtk_widget_set_margin_bottom(page, 20);
gtk_box_append(GTK_BOX(page), top_bar("hwmap", title_key, sub_key));
scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_set_margin_top(scroll, 10);
gtk_box_append(GTK_BOX(page), scroll);
L->stack = gtk_stack_new();
gtk_stack_set_transition_type(GTK_STACK(L->stack),
GTK_STACK_TRANSITION_TYPE_SLIDE_LEFT_RIGHT);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), L->stack);
hw_note_host = L->stack;
for (guint s = 0; s < n_slides; s++) {
GtkWidget *stage = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
GtkWidget *card;
char name[16];
gtk_widget_set_vexpand(stage, TRUE);
g_snprintf(name, sizeof(name), "hw_slide%u", s);
gtk_stack_add_named(GTK_STACK(L->stack), stage, name);
card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 16);
gtk_widget_add_css_class(card, "notes-card");
gtk_widget_set_valign(card, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(stage), card);
net_note_kicker(card, slides[s].kicker);
net_note_title(card, slides[s].title);
for (int i = 0; i < 8 && slides[s].line[i]; i++)
net_note_line(card, slides[s].line[i], FALSE);
if (slides[s].tip)
net_note_line(card, slides[s].tip, TRUE);
}
gtk_widget_add_tick_callback(L->stack, hw_note_tick, NULL, NULL);
hw_note_last_w = -1;
hw_rescale_notes_at(0);
net_notes_target = NULL;
nav = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_box_set_homogeneous(GTK_BOX(nav), TRUE);
gtk_widget_set_margin_top(nav, 10);
gtk_box_append(GTK_BOX(page), nav);
L->prev_btn = gtk_button_new_with_label(tr("net_slide_prev"));
gtk_widget_add_css_class(L->prev_btn, "btn-primary");
g_signal_connect(L->prev_btn, "clicked", G_CALLBACK(hw_slide_prev), L);
gtk_box_append(GTK_BOX(nav), L->prev_btn);
L->next_btn = gtk_button_new_with_label(tr("net_slide_next"));
gtk_widget_add_css_class(L->next_btn, "btn-primary");
g_signal_connect(L->next_btn, "clicked", G_CALLBACK(hw_slide_next), L);
gtk_box_append(GTK_BOX(nav), L->next_btn);
L->idx = 0;
hw_slide_apply(L);
return page;
}
void hw_slide_apply(NetLesson *L) {
char name[16];
if (!L || !L->stack)
return;
g_snprintf(name, sizeof(name), "hw_slide%u", L->idx);
gtk_stack_set_visible_child_name(GTK_STACK(L->stack), name);
if (L->prev_btn) {
gtk_button_set_label(GTK_BUTTON(L->prev_btn), tr("net_slide_prev"));
gtk_widget_set_sensitive(L->prev_btn, L->idx > 0);
}
if (L->next_btn) {
gtk_button_set_label(
GTK_BUTTON(L->next_btn),
L->idx + 1 >= L->n_slides ? tr("net_slide_start")
: tr("net_slide_next"));
}
}
void hw_slide_prev(GtkButton *button, gpointer data) {
NetLesson *L = data;
(void)button;
if (!L) return;
if (L->idx > 0) {
L->idx--;
hw_slide_apply(L);
}
}
void hw_slide_next(GtkButton *button, gpointer data) {
NetLesson *L = data;
(void)button;
if (!L) return;
if (L->idx + 1 < L->n_slides) {
L->idx++;
hw_slide_apply(L);
} else {
gtk_stack_set_visible_child_name(main_stack, L->ex_page);
}
}
void hw_lessons_apply_lang(void) {
for (int i = 0; i < HW_LESSONS; i++)
hw_slide_apply(&hw_lessons[i]);
}
GtkWidget *build_hw_unit1_page(void) {
static const NetSlide slides[HW_SLIDES] = {
{
"1 / 3 • Úvod", "Architektura počítače",
"Architektura popisuje, z jakých částí se počítač skládá "
"a jak spolu spolupracují.",
{
"Architektura počítače je základní model, podle kterého "
"jsou počítače navrhovány.",
"Nejznámější model je Von Neumannovo schéma.",
"Používá se dodnes u většiny běžných počítačů.",
NULL,
},
},
{
"2 / 3 • Osobnost", "John von Neumann",
"Tip: zapamatuj si jméno – podle něj se jmenuje celé schéma.",
{
"John von Neumann byl americký matematik.",
"Pracoval na poli digitálních počítačů a operační teorie "
"kvantové mechaniky.",
"Je autorem Von Neumannovy algebry.",
"Byl tvůrcem teorie her.",
"Podílel se na vývoji atomové a vodíkové bomby.",
NULL,
},
},
{
"3 / 3 • Schéma", "Von Neumannovo schéma",
"ALU = aritmeticko-logická jednotka",
{
"Von Neumannovo schéma má pět hlavních částí:",
"ALU – aritmeticko-logická jednotka (výpočty a logika)",
"Řadič – řídí činnost ostatních částí",
"Operační paměť – uchovává program i data",
"Vstupní jednotka – přivádí data do počítače",
"Výstupní jednotka – předává výsledky ven",
NULL,
},
},
};
return build_hw_unit_page(&hw_lessons[0], "hw_unit1", "hw_unit1_sub",
slides, HW_SLIDES);
}
/* ---- Quiz --------------------------------------------------------- */
static void hw_mcq_check(GtkButton *button, gpointer data) {
NetMcqCtx *ctx = data;
int ok = 0;
(void)button;
for (int i = 0; i < ctx->n; i++) {
int active = -1;
for (int o = 0; o < ctx->n_opts; o++) {
GtkToggleButton *tb = ctx->toggles[i * ctx->n_opts + o];
gtk_widget_remove_css_class(GTK_WIDGET(tb), "ok");
gtk_widget_remove_css_class(GTK_WIDGET(tb), "wrong");
if (gtk_toggle_button_get_active(tb))
active = o;
}
if (active == ctx->qs[i].correct) {
ok++;
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * ctx->n_opts + active]), "ok");
} else if (active >= 0) {
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * ctx->n_opts + active]), "wrong");
}
if (ctx->hints && ctx->hints[i])
gtk_widget_set_visible(ctx->hints[i], TRUE);
}
if (ok == ctx->n) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
mark_hw_done(ctx->lesson_id);
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_retry_short"));
}
}
static GtkWidget *build_hw_mcq_page(int lesson_id, const char *back_page,
const char *title_key,
const char *heading_key,
const ChoiceQ *qs, const char **hints,
int n) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *body;
GtkWidget *check;
GtkWidget *btns;
GtkWidget *head;
GtkWidget *intro;
NetMcqCtx *ctx = g_new0(NetMcqCtx, 1);
int n_opts = qs[0].n_options;
page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
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(back_page, title_key, NULL));
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, 12);
gtk_box_append(GTK_BOX(page), scroll);
body = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
gtk_widget_set_halign(body, GTK_ALIGN_FILL);
gtk_widget_set_hexpand(body, TRUE);
gtk_widget_set_margin_start(body, 4);
gtk_widget_set_margin_end(body, 4);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), body);
head = gtk_label_new(NULL);
i18n_bind(head, heading_key, 0);
gtk_widget_set_halign(head, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(head), TRUE);
gtk_widget_set_margin_top(head, 4);
gtk_widget_add_css_class(head, "quiz-heading");
gtk_box_append(GTK_BOX(body), head);
intro = gtk_label_new(NULL);
i18n_bind(intro, "net_quiz_intro", 0);
gtk_widget_set_halign(intro, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(intro), TRUE);
gtk_widget_add_css_class(intro, "quiz-intro");
gtk_widget_set_margin_bottom(intro, 4);
gtk_box_append(GTK_BOX(body), intro);
ctx->qs = qs;
ctx->n = n;
ctx->n_opts = n_opts;
ctx->lesson_id = lesson_id;
ctx->toggles = g_new0(GtkToggleButton *, n * n_opts);
ctx->hints = g_new0(GtkWidget *, n);
for (int i = 0; i < n; i++) {
GtkWidget *card = mcq_append_question(body, i + 1, &qs[i],
&ctx->toggles[i * n_opts]);
ctx->hints[i] = meaning_add(card, hints[i]);
}
btns = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_margin_top(btns, 8);
gtk_widget_set_margin_bottom(btns, 8);
gtk_box_append(GTK_BOX(body), btns);
check = gtk_button_new();
i18n_bind(check, "check", 1);
gtk_widget_add_css_class(check, "btn-primary");
gtk_widget_set_halign(check, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(btns), check);
g_signal_connect(check, "clicked", G_CALLBACK(hw_mcq_check), ctx);
ctx->feedback = gtk_label_new("");
gtk_widget_set_halign(ctx->feedback, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(body), ctx->feedback);
return page;
}
GtkWidget *build_hw_unit1_exercise_page(void) {
static const ChoiceQ qs[] = {
{"Kdo je autorem Von Neumannova schématu?",
{"Alan Turing", "John von Neumann", "Bill Gates", "Steve Jobs"},
4, 1},
{"Čím byl John von Neumann?",
{"Americký matematik", "Německý fyzik", "Anglický chemik",
"Francouzský lékař"},
4, 0},
{"Co znamená zkratka ALU?",
{"Automatická logická síť", "Aritmeticko-logická jednotka",
"Analogová linková sestava", "Adresová listová sekce"},
4, 1},
{"Která část Von Neumannova schématu řídí ostatní části?",
{"Operační paměť", "Vstupní jednotka", "Řadič", "Výstupní jednotka"},
4, 2},
{"Kde se podle Von Neumannova schématu uchovává program i data?",
{"Jen na pevném disku", "V operační paměti", "Jen ve vstupní jednotce",
"Jen v ALU"},
4, 1},
{"Čím se John von Neumann také zabýval?",
{"Jen malířstvím", "Teorií her", "Jen botanikou", "Jen hudbou"},
4, 1},
};
static const char *hints[] = {
"John von Neumann – americký matematik",
"Americký matematik s objevy v digitálních počítačích",
"ALU = aritmeticko-logická jednotka",
"Řadič řídí činnost ostatních částí",
"Operační paměť uchovává program i data",
"Byl tvůrcem teorie her",
};
return build_hw_mcq_page(0, "hwunit1", "hw_ex1_title", "hw_quiz1_head",
qs, hints, 6);
}
GtkWidget *build_hw_unit2_page(void) {
static const NetSlide slides[HW2_SLIDES] = {
{
"1 / 6 • Přehled", "Historie počítačů",
"Generace se dělí podle klíčové technologie své doby.",
{
"1. Předchůdci – do 30. let 19. století",
"2. Nultá generace – 1938–1944",
"3. První generace – 1944–1955",
"4. Druhá generace – 1955–1964",
"5. Třetí generace – 1964–1971",
"Další snímky shrnují znaky jednotlivých generací.",
NULL,
},
},
{
"2 / 6 • 0. generace", "Počítače 0. generace (1938–1944)",
"Tip: počítačů bylo málo a často sloužily armádě.",
{
"Existovalo jen několik počítačů na světě.",
"Většinou měly vojenský účel.",
NULL,
},
},
{
"3 / 6 • 1. generace", "Počítače 1. generace (1944–1955)",
"Klíčový objev: elektronka",
{
"Objev elektronky umožnil tuto generaci.",
"Ještě neexistoval software v dnešním smyslu.",
"Data a programy se zadávaly pomocí děrných štítků.",
NULL,
},
},
{
"4 / 6 • 2. generace", "Počítače 2. generace (1955–1964)",
"Klíčový objev: tranzistor",
{
"Objev tranzistoru nahradil elektronky.",
"Používala se hlavní externí paměť.",
"Počítače měly menší rozměry než v 1. generaci.",
NULL,
},
},
{
"5 / 6 • 3. generace", "Počítače 3. generace (1964–1971)",
"Klíčový objev: integrovaný obvod",
{
"Objev integrovaného obvodu.",
"Stále se používala hlavní externí paměť.",
"Rozměry počítačů se dál zmenšovaly.",
NULL,
},
},
{
"6 / 6 • 4. generace", "Počítače 4. generace",
"Klíčové: programovatelné mikroprocesory",
{
"Nastupují programovatelné mikroprocesory.",
"1972 – první počítačová hra",
"1973 – první disketa",
"1979 – první CD",
NULL,
},
},
};
return build_hw_unit_page(&hw_lessons[1], "hw_unit2", "hw_unit2_sub",
slides, HW2_SLIDES);
}
GtkWidget *build_hw_unit2_exercise_page(void) {
static const ChoiceQ qs[] = {
{"Kdy probíhala nultá generace počítačů?",
{"1938–1944", "1944–1955", "1955–1964", "1964–1971"}, 4, 0},
{"Co bylo typické pro počítače 0. generace?",
{"Byly v každé domácnosti", "Jen několik kusů, často vojenský účel",
"Měly mikroprocesory", "Používaly CD"},
4, 1},
{"Která technologie patří k 1. generaci?",
{"Tranzistor", "Elektronka", "Integrovaný obvod", "Mikroprocesor"},
4, 1},
{"Čím se zadávaly data v 1. generaci?",
{"USB fleškou", "Děrnými štítky", "CD", "Disketou"}, 4, 1},
{"Která technologie patří k 2. generaci?",
{"Elektronka", "Tranzistor", "Mikroprocesor", "CD"}, 4, 1},
{"Která technologie patří k 3. generaci?",
{"Elektronka", "Tranzistor", "Integrovaný obvod", "Disketa"},
4, 2},
{"Co je typické pro 4. generaci?",
{"Jen děrné štítky", "Programovatelné mikroprocesory",
"Jen vojenské použití", "Žádný software"},
4, 1},
{"Kdy vznikla první počítačová hra (podle lekce)?",
{"1964", "1972", "1973", "1979"}, 4, 1},
};
static const char *hints[] = {
"Nultá generace: 1938–1944",
"Jen několik počítačů, většinou vojenský účel",
"1. generace – objev elektronky",
"1. generace – děrné štítky",
"2. generace – objev tranzistoru",
"3. generace – integrovaný obvod",
"4. generace – programovatelné mikroprocesory",
"1972 – první počítačová hra",
};
return build_hw_mcq_page(1, "hwunit2", "hw_ex2_title", "hw_quiz2_head",
qs, hints, 8);
}
GtkWidget *build_hw_unit3_page(void) {
static const NetSlide slides[HW3_SLIDES] = {
{
"1 / 2 • Bit", "Bit – nejmenší jednotka informace",
"Bit = binary digit (dvojková číslice)",
{
"Bit je dvojková číslice – nabývá hodnoty 0 nebo 1.",
"Je to nejmenší zobrazitelná jednotka informace.",
"Z bitů se skládají větší jednotky (např. byte).",
NULL,
},
},
{
"2 / 2 • Byte", "Byte – adresovatelná jednotka paměti",
"Tip: 1 B = 8 b",
{
"Byte je nejmenší adresovatelná jednotka paměti.",
"Počítač tedy paměť adresuje po bytech, ne po jednotlivých "
"bitech.",
"Jeden byte obvykle obsahuje 8 bitů.",
NULL,
},
},
};
return build_hw_unit_page(&hw_lessons[2], "hw_unit3", "hw_unit3_sub",
slides, HW3_SLIDES);
}
GtkWidget *build_hw_unit3_exercise_page(void) {
static const ChoiceQ qs[] = {
{"Co je bit?",
{"Největší jednotka paměti", "Dvojková číslice – nejmenší jednotka "
"informace", "Jen klávesa na klávesnici", "Typ procesoru"},
4, 1},
{"Jaké hodnoty může nabývat bit?",
{"Jen 0", "Jen 1", "0 nebo 1", "0 až 255"}, 4, 2},
{"Co je byte?",
{"Nejmenší adresovatelná jednotka paměti",
"Nejmenší zobrazitelná jednotka informace",
"Jen síťový kabel", "Jen grafická karta"},
4, 0},
{"Kolik bitů má obvykle jeden byte?",
{"2", "4", "8", "16"}, 4, 2},
};
static const char *hints[] = {
"Bit = dvojková číslice, nejmenší jednotka informace",
"Bit nabývá hodnoty 0 nebo 1",
"Byte = nejmenší adresovatelná jednotka paměti",
"1 B = 8 b",
};
return build_hw_mcq_page(2, "hwunit3", "hw_ex3_title", "hw_quiz3_head",
qs, hints, 4);
}

1510
src/i18n.c Normal file

File diff suppressed because it is too large Load diff

467
src/icons.c Normal file
View file

@ -0,0 +1,467 @@
#include "maturita.h"
/* Vector icons drawn with cairo so they never depend on the system
* icon theme (which is missing on some installs). */
void draw_check_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
Rgb c = color_from_hex(app_theme.on_accent);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
cairo_set_line_width(cr, width * 0.16);
cairo_move_to(cr, width * 0.24, height * 0.56);
cairo_line_to(cr, width * 0.43, height * 0.74);
cairo_line_to(cr, width * 0.78, height * 0.30);
cairo_stroke(cr);
}
/* Paint a padlock centred in the size x size box with top-left (x0,y0). */
void lock_paint(cairo_t *cr, double x0, double y0, double size,
const Rgb *c) {
double sl = MAX(1.6, size * 0.13); /* shackle stroke width */
double bw = size * 0.60; /* body width */
double bh = size * 0.44; /* body height */
double cx = x0 + size / 2.0;
double body_top = y0 + size - bh - size * 0.04;
double bx = cx - bw / 2.0;
double r = bw / 2.0 - sl / 2.0; /* shackle arc radius */
cairo_set_source_rgb(cr, c->r, c->g, c->b);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_width(cr, sl);
cairo_new_path(cr);
cairo_arc(cr, cx, body_top, r, G_PI, 2.0 * G_PI);
cairo_stroke(cr);
cairo_new_path(cr);
cairo_rectangle(cr, bx, body_top, bw, bh);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, cx, body_top + bh * 0.42, size * 0.07, 0.0, 2.0 * G_PI);
{
Rgb hole = color_from_hex(app_theme.crust);
cairo_set_source_rgb(cr, hole.r, hole.g, hole.b);
}
cairo_fill(cr);
}
void draw_lock_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
Rgb c = color_from_hex(app_theme.overlay);
(void)area;
(void)data;
lock_paint(cr, 0.0, 0.0, MIN(width, height), &c);
}
/* Wireless icon: three upward arcs that fan out from a base dot. */
void draw_wifi_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double s = MIN(width, height);
const double cx = width / 2.0;
const double cy = height * 0.68;
const double dot_r = s * 0.06;
const double radii[3] = {s * 0.15, s * 0.27, s * 0.39};
const double phi = 0.75;
/* Keep the icon legible on the accent-gradient bubble while giving it a
* hint of the active theme: blend the contrasting on-accent colour a
* little way towards the palette's middle accent. */
const Rgb c = mix_rgb(color_from_hex(app_theme.on_accent),
color_from_hex(app_theme.accent2), 0.22);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_width(cr, MAX(1.5, s * 0.07));
for (int i = 0; i < 3; i++) {
double r = radii[i];
cairo_new_path(cr);
cairo_arc(cr, cx, cy, r, -G_PI / 2.0 - phi, -G_PI / 2.0 + phi);
cairo_stroke(cr);
}
cairo_new_path(cr);
cairo_arc(cr, cx, cy, dot_r, 0.0, 2.0 * G_PI);
cairo_fill(cr);
}
/* CPU / chip icon for Technické vybavení. */
void draw_chip_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double s = MIN(width, height);
const double cx = width / 2.0;
const double cy = height / 2.0;
const double body = s * 0.42;
const double pin_len = s * 0.12;
const double pin_w = s * 0.055;
const Rgb c = mix_rgb(color_from_hex(app_theme.on_accent),
color_from_hex(app_theme.accent2), 0.22);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_rectangle(cr, cx - body / 2.0, cy - body / 2.0, body, body);
cairo_fill(cr);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_SQUARE);
cairo_set_line_width(cr, pin_w);
for (int i = 0; i < 4; i++) {
double t = (i + 0.5) / 4.0;
double x = cx - body / 2.0 + t * body;
double y = cy - body / 2.0 + t * body;
cairo_move_to(cr, x, cy - body / 2.0);
cairo_line_to(cr, x, cy - body / 2.0 - pin_len);
cairo_move_to(cr, x, cy + body / 2.0);
cairo_line_to(cr, x, cy + body / 2.0 + pin_len);
cairo_move_to(cr, cx - body / 2.0, y);
cairo_line_to(cr, cx - body / 2.0 - pin_len, y);
cairo_move_to(cr, cx + body / 2.0, y);
cairo_line_to(cr, cx + body / 2.0 + pin_len, y);
}
cairo_stroke(cr);
{
Rgb hole = color_from_hex(app_theme.crust);
double core = body * 0.34;
cairo_set_source_rgb(cr, hole.r, hole.g, hole.b);
cairo_rectangle(cr, cx - core / 2.0, cy - core / 2.0, core, core);
cairo_fill(cr);
}
}
/* Open book glyph, painted in the given colour. */
static void book_paint(cairo_t *cr, int width, int height, Rgb c) {
const double w = width;
const double h = height;
const double mid = w * 0.5;
const double gap = w * 0.045; /* spine gap so the pages read apart */
const double lmid = mid - gap;
const double rmid = mid + gap;
const double left = w * 0.13;
const double right = w * 0.87;
const double top = h * 0.24;
const double bot = h * 0.80;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_new_path(cr);
cairo_move_to(cr, lmid, top);
cairo_curve_to(cr, w * 0.40, top - h * 0.07,
w * 0.24, top - h * 0.02, left, top + h * 0.07);
cairo_line_to(cr, left, bot - h * 0.05);
cairo_curve_to(cr, w * 0.24, bot - h * 0.11,
w * 0.40, bot - h * 0.05, lmid, bot);
cairo_close_path(cr);
cairo_fill(cr);
cairo_new_path(cr);
cairo_move_to(cr, rmid, top);
cairo_curve_to(cr, w * 0.60, top - h * 0.07,
w * 0.76, top - h * 0.02, right, top + h * 0.07);
cairo_line_to(cr, right, bot - h * 0.05);
cairo_curve_to(cr, w * 0.76, bot - h * 0.11,
w * 0.60, bot - h * 0.05, rmid, bot);
cairo_close_path(cr);
cairo_fill(cr);
}
/* Open book icon – used on light surfaces (cards, section headers). */
void draw_book_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const Rgb c = mix_rgb(color_from_hex(app_theme.accent2),
color_from_hex(app_theme.accent3), 0.35);
(void)area;
(void)data;
book_paint(cr, width, height, c);
}
/* Open book icon for the accent-gradient roadmap bubbles: uses the
* contrasting on-accent colour so it stays visible on the gradient. */
void draw_book_badge_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const Rgb c = mix_rgb(color_from_hex(app_theme.on_accent),
color_from_hex(app_theme.accent2), 0.22);
(void)area;
(void)data;
book_paint(cr, width, height, c);
}
/* Question mark in a circle – quiz. */
void draw_quiz_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const Rgb c = color_from_hex(app_theme.accent);
PangoLayout *layout;
PangoFontDescription *fd;
int tw, th;
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
layout = pango_cairo_create_layout(cr);
fd = pango_font_description_new();
pango_font_description_set_weight(fd, PANGO_WEIGHT_HEAVY);
pango_font_description_set_absolute_size(
fd, MIN(width, height) * 1.05 * PANGO_SCALE);
pango_layout_set_font_description(layout, fd);
pango_layout_set_text(layout, "?", -1);
pango_layout_get_pixel_size(layout, &tw, &th);
cairo_move_to(cr, (width - tw) / 2.0, (height - th) / 2.0);
pango_cairo_show_layout(cr, layout);
pango_font_description_free(fd);
g_object_unref(layout);
}
/* Two opposing arrows – ordering / drag & drop. */
void draw_order_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double w = width;
const double h = height;
const Rgb c = color_from_hex(app_theme.success2);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_set_line_width(cr, MAX(1.6, MIN(w, h) * 0.11));
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
cairo_new_path(cr);
cairo_move_to(cr, w * 0.36, h * 0.80);
cairo_line_to(cr, w * 0.36, h * 0.24);
cairo_move_to(cr, w * 0.22, h * 0.40);
cairo_line_to(cr, w * 0.36, h * 0.24);
cairo_line_to(cr, w * 0.50, h * 0.40);
cairo_stroke(cr);
cairo_new_path(cr);
cairo_move_to(cr, w * 0.64, h * 0.20);
cairo_line_to(cr, w * 0.64, h * 0.76);
cairo_move_to(cr, w * 0.50, h * 0.60);
cairo_line_to(cr, w * 0.64, h * 0.76);
cairo_line_to(cr, w * 0.78, h * 0.60);
cairo_stroke(cr);
}
/* Globe – the world / superpowers. */
void draw_globe_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double cx = width / 2.0;
const double cy = height / 2.0;
const double r = MIN(width, height) * 0.36;
const Rgb c = color_from_hex(app_theme.accent2);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_set_line_width(cr, MAX(1.4, r * 0.13));
cairo_new_path(cr);
cairo_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_stroke(cr);
cairo_save(cr);
cairo_translate(cr, cx, cy);
cairo_scale(cr, 0.52, 1.0);
cairo_new_path(cr);
cairo_arc(cr, 0.0, 0.0, r, 0.0, 2.0 * G_PI);
cairo_stroke(cr);
cairo_restore(cr);
cairo_new_path(cr);
cairo_move_to(cr, cx - r, cy);
cairo_line_to(cr, cx + r, cy);
cairo_move_to(cr, cx - r * 0.86, cy - r * 0.52);
cairo_line_to(cr, cx + r * 0.86, cy - r * 0.52);
cairo_move_to(cr, cx - r * 0.86, cy + r * 0.52);
cairo_line_to(cr, cx + r * 0.86, cy + r * 0.52);
cairo_stroke(cr);
}
/* Two people – characters. */
void draw_people_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const Rgb c = color_from_hex(app_theme.accent3);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_new_path(cr);
cairo_arc(cr, width * 0.38, height * 0.36, width * 0.15,
0.0, 2.0 * G_PI);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, width * 0.38, height * 1.02, width * 0.30,
G_PI, 2.0 * G_PI);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, width * 0.67, height * 0.42, width * 0.13,
0.0, 2.0 * G_PI);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, width * 0.67, height * 1.06, width * 0.27,
G_PI, 2.0 * G_PI);
cairo_fill(cr);
}
/* Light bulb – key terms / ideas. */
void draw_bulb_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double cx = width / 2.0;
const double r = MIN(width, height) * 0.30;
const double cy = height * 0.40;
const Rgb c = color_from_hex(app_theme.warning);
(void)area;
(void)data;
cairo_set_source_rgb(cr, c.r, c.g, c.b);
cairo_new_path(cr);
cairo_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_fill(cr);
cairo_new_path(cr);
cairo_rectangle(cr, cx - r * 0.45, cy + r * 0.72,
r * 0.90, r * 0.66);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, cx, cy + r * 1.72, r * 0.52, 0.0, G_PI);
cairo_fill(cr);
}
void draw_back_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double size = 15.0; /* fixed logical icon size */
const double stroke = 1.8;
double ox = (width - size) / 2.0;
double oy = (height - size) / 2.0;
GtkWidget *parent = gtk_widget_get_parent(GTK_WIDGET(area));
GdkRGBA color;
(void)data;
gtk_style_context_get_color(gtk_widget_get_style_context(parent), &color);
cairo_set_source_rgb(cr, color.red, color.green, color.blue);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
cairo_set_line_join(cr, CAIRO_LINE_JOIN_ROUND);
cairo_set_line_width(cr, stroke);
cairo_move_to(cr, ox + size * 0.72, oy + size * 0.24);
cairo_line_to(cr, ox + size * 0.30, oy + size * 0.50);
cairo_line_to(cr, ox + size * 0.72, oy + size * 0.76);
cairo_stroke(cr);
}
void draw_settings_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const int teeth = 8;
const double size = 16.0;
double cx = width / 2.0;
double cy = height / 2.0;
double r_outer = size * 0.48;
double r_inner = size * 0.34;
double r_hole = size * 0.16;
double tooth_half = G_PI / (double)teeth;
GtkWidget *color_w = data ? GTK_WIDGET(data)
: gtk_widget_get_parent(GTK_WIDGET(area));
GdkRGBA color;
int i;
gtk_style_context_get_color(gtk_widget_get_style_context(color_w), &color);
cairo_set_source_rgb(cr, color.red, color.green, color.blue);
cairo_set_fill_rule(cr, CAIRO_FILL_RULE_EVEN_ODD);
cairo_new_path(cr);
for (i = 0; i < teeth; i++) {
double a = -G_PI / 2.0 + (2.0 * G_PI * (double)i) / (double)teeth;
double a0 = a - tooth_half * 0.55;
double a1 = a - tooth_half * 0.28;
double a2 = a + tooth_half * 0.28;
double a3 = a + tooth_half * 0.55;
if (i == 0)
cairo_move_to(cr, cx + cos(a0) * r_inner, cy + sin(a0) * r_inner);
else
cairo_line_to(cr, cx + cos(a0) * r_inner, cy + sin(a0) * r_inner);
cairo_line_to(cr, cx + cos(a1) * r_outer, cy + sin(a1) * r_outer);
cairo_line_to(cr, cx + cos(a2) * r_outer, cy + sin(a2) * r_outer);
cairo_line_to(cr, cx + cos(a3) * r_inner, cy + sin(a3) * r_inner);
}
cairo_close_path(cr);
cairo_new_sub_path(cr);
cairo_arc(cr, cx, cy, r_hole, 0.0, 2.0 * G_PI);
cairo_fill(cr);
}
void draw_stats_icon(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
GtkWidget *color_w = data ? GTK_WIDGET(data)
: gtk_widget_get_parent(GTK_WIDGET(area));
GdkRGBA color;
const double bar_w = 3.2;
const double gap = 2.6;
const double total_w = 3.0 * bar_w + 2.0 * gap;
const double heights[3] = {0.42, 0.72, 1.0};
double base_x = (width - total_w) / 2.0;
double base_y = height * 0.82;
double max_h = height * 0.58;
int i;
(void)area;
gtk_style_context_get_color(gtk_widget_get_style_context(color_w), &color);
cairo_set_source_rgb(cr, color.red, color.green, color.blue);
cairo_set_line_width(cr, 1.5);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_ROUND);
for (i = 0; i < 3; i++) {
double x = base_x + (double)i * (bar_w + gap);
double h = max_h * heights[i];
double y = base_y - h;
double r = 1.2;
cairo_new_sub_path(cr);
cairo_arc(cr, x + r, y + r, r, G_PI, 1.5 * G_PI);
cairo_arc(cr, x + bar_w - r, y + r, r, 1.5 * G_PI, 2.0 * G_PI);
cairo_line_to(cr, x + bar_w, base_y);
cairo_line_to(cr, x, base_y);
cairo_close_path(cr);
cairo_fill(cr);
}
}
GtkWidget *icon_area_new(GtkDrawingAreaDrawFunc fn,
double r, double g, double b, int px) {
Rgb *col = g_new(Rgb, 1);
GtkWidget *d = gtk_drawing_area_new();
col->r = r;
col->g = g;
col->b = b;
gtk_widget_set_size_request(d, px, px);
gtk_widget_set_halign(d, GTK_ALIGN_CENTER);
gtk_widget_set_valign(d, GTK_ALIGN_CENTER);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(d), fn,
col, (GDestroyNotify)g_free);
return d;
}

55
src/macos_dock.c Normal file
View file

@ -0,0 +1,55 @@
#include "maturita.h"
#ifdef __APPLE__
#include <objc/message.h>
#include <objc/runtime.h>
/* Set the macOS Dock tile icon. GTK's window icon-name is ignored on
* Quartz, so we talk to AppKit directly via the Objective-C runtime. */
void macos_set_dock_icon(const char *path) {
Class nsstring;
Class nsimage;
Class nsapp;
id path_str;
id image;
id app;
if (!path || !path[0])
return;
nsstring = objc_getClass("NSString");
nsimage = objc_getClass("NSImage");
nsapp = objc_getClass("NSApplication");
if (!nsstring || !nsimage || !nsapp)
return;
path_str = ((id (*)(Class, SEL, const char *))objc_msgSend)(
nsstring, sel_registerName("stringWithUTF8String:"), path);
if (!path_str)
return;
/* NSImage has -initWithContentsOfFile:, not +imageWithContentsOfFile:. */
image = ((id (*)(id, SEL, id))objc_msgSend)(
((id (*)(Class, SEL))objc_msgSend)(
nsimage, sel_registerName("alloc")),
sel_registerName("initWithContentsOfFile:"),
path_str);
if (!image)
return;
app = ((id (*)(Class, SEL))objc_msgSend)(
nsapp, sel_registerName("sharedApplication"));
if (!app) {
((void (*)(id, SEL))objc_msgSend)(image, sel_registerName("release"));
return;
}
((void (*)(id, SEL, id))objc_msgSend)(
app, sel_registerName("setApplicationIconImage:"), image);
((void (*)(id, SEL))objc_msgSend)(image, sel_registerName("release"));
}
#else
void macos_set_dock_icon(const char *path) {
(void)path;
}
#endif

652
src/main.c Normal file
View file

@ -0,0 +1,652 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Activate */
/* ------------------------------------------------------------------ */
void unit_meta_init(void) {
static const char *titles[NUM_UNITS] = {
"Neue Freunde", "Aus aller Welt", "Bei uns zu Hause",
"Schule und Freizeit", "Guten Appetit!", "Mein Tagesablauf",
"Meine Freunde", "Wir treffen uns in Salzburg",
"Mein Haus ist meine Burg", "Urlaub in Österreich",
};
static const char *pages[NUM_UNITS] = {
"unit1", "unit2", "unit3", NULL, NULL, NULL, NULL, NULL, NULL, NULL,
};
static const char *tags[NUM_UNITS] = {
"u1", "u2", "u3", NULL, NULL, NULL, NULL, NULL, NULL, NULL,
};
for (int i = 0; i < NUM_UNITS; i++) {
units[i].title = titles[i];
units[i].page = pages[i];
units[i].ex_tag = tags[i];
units[i].sub_key = NULL;
units[i].unlocked = (i < NUM_UNLOCKED);
}
units[0].sub_key = "unit1_sub";
units[0].progress_file = PROGRESS_U1;
units[1].sub_key = "unit2_sub";
units[1].progress_file = PROGRESS_U2;
units[2].sub_key = "unit3_sub";
units[2].progress_file = PROGRESS_U3;
/* Off-path branch hanging above exercise 2 of unit 1. */
units[0].has_branch = TRUE;
units[0].branch_name = "Vokabeltraining";
units[0].branch_target = "u1vocab";
units[0].branch_ex = MAX_UNIT_EX;
units[0].trans_sections = u1_trans_sections;
units[0].n_trans_sections = u1_trans_sections_n;
/* Same vocabulary-training branch for unit 2 ("Aus aller Welt"). */
units[1].has_branch = TRUE;
units[1].branch_name = "Vokabeltraining";
units[1].branch_target = "u2vocab";
units[1].branch_ex = MAX_UNIT_EX;
units[1].trans_sections = u2_trans_sections;
units[1].n_trans_sections = u2_trans_sections_n;
/* Same vocabulary-training branch for unit 3 ("Bei uns zu Hause"). */
units[2].has_branch = TRUE;
units[2].branch_name = "Vokabeltraining";
units[2].branch_target = "u3vocab";
units[2].branch_ex = MAX_UNIT_EX;
units[2].trans_sections = u3_trans_sections;
units[2].n_trans_sections = u3_trans_sections_n;
}
void unit_configure(int idx, const char *const *names, int n) {
UnitCtx *u = &units[idx];
u->n_ex = n;
for (int i = 1; i <= n; i++)
u->ex_names[i] = names[i];
}
GtkWidget *build_exercise_page(UnitCtx *u, int n) {
if (u->ex_tag[1] == '1') {
switch (n) {
case 1: return build_ex1(u);
case 2: return build_assembly(u, "Sätze bilden", "sub_assembly", 2,
ex2_items, ex2_meaning,
4);
case 3: return build_choice(u, "Was ist richtig?", "sub_choice_num",
3, ex3_questions, ex3_meaning,
4);
case 4: return build_ex4(u);
case 5: return build_ex5(u);
case 6: return build_ex6(u);
case 7: return build_ex7(u);
case 8: return build_ex8(u);
case 9: return build_choice(u, "Wer? Wie? Wo?", "sub_wer",
9, ex9_questions, ex9_meaning,
5);
case 10: return build_assembly(u, "Wörter trennen", "sub_assembly",
10, ex10_items, ex10_meaning,
5);
case 11: return build_assign(u, "Grußformen", "sub_gruss",
11, ex11_items,
12,
ex11_groups, 2,
ex11_meaning);
case 12: return build_ex12(u);
case 13: return build_assign(u, "Länder", "sub_land",
13, ex13_items,
10,
ex13_groups, 3,
ex13_meaning);
default: return NULL;
}
} else if (u->ex_tag[1] == '2') {
switch (n) {
case 1: return u2ex1(u);
case 2: return u2ex2(u);
case 3: return u2ex3(u);
case 4: return u2ex4(u);
case 5: return u2ex5(u);
case 6: return u2ex6(u);
case 7: return u2ex7(u);
case 8: return u2ex8(u);
case 9: return u2ex9(u);
case 10: return u2ex10(u);
case 11: return u2ex11(u);
case 12: return u2ex12(u);
case 13: return u2ex13(u);
case 14: return u2ex14(u);
case 15: return u2ex15(u);
case 16: return u2ex16(u);
case 17: return u2ex17(u);
case 18: return u2ex18(u);
case 19: return u2ex19(u);
default: return NULL;
}
} else {
switch (n) {
case 1: return u3ex1(u);
case 2: return u3ex2(u);
case 3: return u3ex3(u);
case 4: return u3ex4(u);
case 5: return u3ex5(u);
case 6: return u3ex6(u);
case 7: return u3ex7(u);
case 8: return u3ex8(u);
case 9: return u3ex9(u);
case 10: return u3ex10(u);
case 11: return u3ex11(u);
case 12: return u3ex12(u);
case 13: return u3ex13(u);
case 14: return u3ex14(u);
case 15: return u3ex15(u);
default: return NULL;
}
}
}
static int ui_scale_last_w = -1;
static guint ui_scale_timer = 0;
static int ui_scale_pending_w = 0;
static gboolean ui_scale_apply(gpointer data) {
double scale;
(void)data;
ui_scale_timer = 0;
scale = 1.0 + (ui_scale_pending_w - 760) / 1900.0;
if (scale < 1.0)
scale = 1.0;
if (scale > 1.5)
scale = 1.5;
scale = (double)((int)(scale * 20.0 + 0.5)) / 20.0;
if (scale != app_ui_scale) {
app_ui_scale = scale;
apply_theme();
}
return G_SOURCE_REMOVE;
}
/* Grow the interface's text as the window gets wider, so it stays comfortable
* when maximised on a large or high-DPI display. Debounced to avoid reloading
* the stylesheet on every frame while dragging. */
static gboolean ui_scale_tick(GtkWidget *w, GdkFrameClock *clock,
gpointer data) {
int width = gtk_widget_get_allocated_width(w);
(void)clock;
(void)data;
if (width < 200 || width == ui_scale_last_w)
return G_SOURCE_CONTINUE;
ui_scale_last_w = width;
ui_scale_pending_w = width;
if (ui_scale_timer)
g_source_remove(ui_scale_timer);
ui_scale_timer = g_timeout_add(120, ui_scale_apply, NULL);
return G_SOURCE_CONTINUE;
}
static const char *find_app_icon_file(void) {
static const char *paths[] = {
ICON_FILE,
ICON_THEME_DIR "/hicolor/512x512/apps/" ICON_NAME ".png",
SHARE_DIR "/icons/hicolor/512x512/apps/" ICON_NAME ".png",
ICON_FILE_ALT,
NULL,
};
int i;
for (i = 0; paths[i]; i++) {
if (g_file_test(paths[i], G_FILE_TEST_IS_REGULAR))
return paths[i];
}
return NULL;
}
/* Kept alive for gdk_toplevel_set_icon_list() which does not take ownership. */
static GList *app_icon_textures;
static void clear_app_icon_textures(void) {
g_list_free_full(app_icon_textures, g_object_unref);
app_icon_textures = NULL;
}
/* X11 / some compositors use the toplevel icon list for the taskbar tile. */
static void apply_toplevel_icon(GtkWindow *window) {
GdkSurface *surface;
const char *path;
GError *err = NULL;
GdkTexture *tex;
surface = gtk_native_get_surface(GTK_NATIVE(window));
if (!surface || !GDK_IS_TOPLEVEL(surface))
return;
path = find_app_icon_file();
if (!path)
return;
tex = gdk_texture_new_from_filename(path, &err);
if (!tex) {
g_clear_error(&err);
return;
}
clear_app_icon_textures();
app_icon_textures = g_list_append(NULL, tex);
gdk_toplevel_set_icon_list(GDK_TOPLEVEL(surface), app_icon_textures);
}
static void on_window_realize_icon(GtkWidget *widget, gpointer data) {
(void)data;
apply_toplevel_icon(GTK_WINDOW(widget));
}
/* Prepend ./share to XDG_DATA_DIRS so Wayland can find our .desktop file and
* hicolor icons without a system install (matches APP_ID). */
static void setup_portable_share_dir(void) {
char *abs;
const char *old;
char *neu;
if (!g_file_test(SHARE_DIR "/applications", G_FILE_TEST_IS_DIR) &&
!g_file_test(SHARE_DIR "/icons", G_FILE_TEST_IS_DIR))
return;
abs = g_canonicalize_filename(SHARE_DIR, NULL);
if (!abs)
return;
old = g_getenv("XDG_DATA_DIRS");
if (old && old[0])
neu = g_strconcat(abs, G_SEARCHPATH_SEPARATOR_S, old, NULL);
else
neu = g_strconcat(abs,
G_SEARCHPATH_SEPARATOR_S "/usr/local/share"
G_SEARCHPATH_SEPARATOR_S "/usr/share",
NULL);
g_setenv("XDG_DATA_DIRS", neu, TRUE);
g_free(neu);
g_free(abs);
}
/* Register icon theme paths + window icon-name (Linux), AppKit Dock (macOS),
* and toplevel textures (X11 / fallbacks). Windows uses the .ico embedded
* via maturita.rc at link time. */
static void setup_window_icon(GtkWindow *window) {
GtkIconTheme *theme;
static const char *dirs[] = {
ICON_THEME_DIR,
ICON_THEME_DIR_ALT,
SHARE_DIR "/icons",
NULL,
};
const char *icon_file;
int i;
theme = gtk_icon_theme_get_for_display(gdk_display_get_default());
for (i = 0; dirs[i]; i++) {
if (g_file_test(dirs[i], G_FILE_TEST_IS_DIR))
gtk_icon_theme_add_search_path(theme, dirs[i]);
}
if (gtk_icon_theme_has_icon(theme, ICON_NAME)) {
gtk_window_set_default_icon_name(ICON_NAME);
gtk_window_set_icon_name(window, ICON_NAME);
} else {
g_warning("App icon '%s' not found (tried %s/, %s/, %s/icons/)",
ICON_NAME, ICON_THEME_DIR, ICON_THEME_DIR_ALT, SHARE_DIR);
}
icon_file = find_app_icon_file();
if (icon_file)
macos_set_dock_icon(icon_file);
#ifdef __APPLE__
else
g_warning("Dock icon PNG not found (tried %s and %s)",
ICON_FILE, ICON_FILE_ALT);
#endif
g_signal_connect(window, "realize",
G_CALLBACK(on_window_realize_icon), NULL);
}
static gboolean open_settings_later(gpointer data) {
(void)data;
settings_open();
return G_SOURCE_REMOVE;
}
static gboolean save_window_png(const char *path) {
GtkWidget *widget;
int width, height;
GdkPaintable *paintable;
GtkSnapshot *snapshot;
GskRenderNode *node;
cairo_surface_t *surface;
cairo_t *cr;
cairo_status_t st;
if (!main_window || !path || !path[0])
return FALSE;
widget = GTK_WIDGET(main_window);
width = gtk_widget_get_width(widget);
height = gtk_widget_get_height(widget);
if (width < 80 || height < 80)
return FALSE;
paintable = gtk_widget_paintable_new(widget);
snapshot = gtk_snapshot_new();
gdk_paintable_snapshot(paintable, snapshot, width, height);
node = gtk_snapshot_free_to_node(snapshot);
g_object_unref(paintable);
if (!node)
return FALSE;
surface = cairo_image_surface_create(CAIRO_FORMAT_ARGB32, width, height);
cr = cairo_create(surface);
gsk_render_node_draw(node, cr);
gsk_render_node_unref(node);
cairo_destroy(cr);
st = cairo_surface_write_to_png(surface, path);
cairo_surface_destroy(surface);
return st == CAIRO_STATUS_SUCCESS;
}
static void smoke_quit(int code) {
GtkApplication *app = main_window
? gtk_window_get_application(main_window) : NULL;
if (app)
g_application_quit(G_APPLICATION(app));
else
exit(code);
}
static gboolean shot_later(gpointer data) {
const char *path = g_getenv("MATURITA_SHOT");
int *tries = data;
if (!save_window_png(path)) {
if (tries && *tries < 20) {
(*tries)++;
return G_SOURCE_CONTINUE;
}
g_printerr("MATURITA_SHOT: could not capture window\n");
smoke_quit(1);
return G_SOURCE_REMOVE;
}
if (g_getenv("MATURITA_SHOT_QUIT"))
smoke_quit(0);
return G_SOURCE_REMOVE;
}
static gboolean run_smoke_test(gpointer data) {
static const char *pages[] = {
"welcome", "subjects", "roadmap", "stats",
"netyears", "netmap", "hwmap", "czechmap",
"mluvnice", "readinglist",
"cetba1984", "cetba1984quiz", "cetba1984dej",
"cetbaFuks", "cetbaFuksQuiz", "cetbaFuksDej",
"hwunit1", "hwex1", "hwunit2", "hwex2", "hwunit3", "hwex3",
"unit1", "unit2", "unit3",
"u1e1", "u1e13", "u2e1", "u2e19", "u3e1", "u3e15",
"u1vocab", "u2vocab", "u3vocab",
"mluve1", "mluve20",
"netunit1", "netex1", "netunit10", "netex10",
"netunit27", "netex27",
NULL
};
const char *report = g_getenv("MATURITA_SMOKE");
FILE *f;
int missing = 0;
int i;
(void)data;
f = report && report[0] ? fopen(report, "w") : stdout;
if (!f) {
g_printerr("MATURITA_SMOKE: cannot write %s\n", report);
smoke_quit(1);
return G_SOURCE_REMOVE;
}
fprintf(f, "commit=%s\n", APP_COMMIT);
fprintf(f, "css=%s\n",
g_file_test(CSS_FILE, G_FILE_TEST_IS_REGULAR) ? "ok" : "MISSING");
fprintf(f, "progress_dir=%s\n", app_progress_dir ? app_progress_dir : "?");
for (i = 0; pages[i]; i++) {
GtkWidget *child = gtk_stack_get_child_by_name(main_stack, pages[i]);
if (child) {
fprintf(f, "OK %s\n", pages[i]);
} else {
fprintf(f, "MISSING %s\n", pages[i]);
missing++;
}
}
fprintf(f, "missing=%d\n", missing);
if (f != stdout)
fclose(f);
smoke_quit(missing ? 1 : 0);
return G_SOURCE_REMOVE;
}
void activate(GtkApplication *app, gpointer user_data) {
GtkWindow *window;
GtkWidget *headerbar;
GtkWidget *title_label;
GtkWidget *welcome_page;
GtkWidget *roadmap_page;
GtkEventController *keys;
(void)user_data;
load_settings();
unit_meta_init();
unit_configure(0, u1_ex_names, 13);
unit_configure(1, u2_ex_names, 19);
unit_configure(2, u3_ex_names, 15);
load_progress();
mluvnice_init();
app_theme = theme_palette(app_theme_id, app_color_mode);
window = GTK_WINDOW(gtk_application_window_new(app));
main_window = window;
gtk_window_set_title(window, "maturita.c");
gtk_window_set_default_size(window, 760, 560);
setup_window_icon(window);
headerbar = gtk_header_bar_new();
gtk_widget_add_css_class(headerbar, "titlebar");
title_label = gtk_label_new("maturita.c");
gtk_widget_add_css_class(title_label, "app-title");
gtk_header_bar_set_title_widget(GTK_HEADER_BAR(headerbar), title_label);
gtk_window_set_titlebar(window, headerbar);
apply_theme();
gtk_header_bar_pack_end(GTK_HEADER_BAR(headerbar), build_settings_button());
gtk_header_bar_pack_end(GTK_HEADER_BAR(headerbar), build_stats_button());
main_stack = GTK_STACK(gtk_stack_new());
gtk_stack_set_transition_type(GTK_STACK(main_stack),
GTK_STACK_TRANSITION_TYPE_CROSSFADE);
/* The update banner sits above the pages and stays hidden until there is
* something to report, so it never shifts the layout unprompted. */
{
GtkWidget *root = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(root), build_update_banner());
gtk_widget_set_vexpand(GTK_WIDGET(main_stack), TRUE);
gtk_box_append(GTK_BOX(root), GTK_WIDGET(main_stack));
gtk_window_set_child(window, root);
}
welcome_page = build_welcome_page();
roadmap_page = build_roadmap_page();
gtk_stack_add_named(main_stack, welcome_page, "welcome");
gtk_stack_add_named(main_stack, build_subjects_page(), "subjects");
gtk_stack_add_named(main_stack, roadmap_page, "roadmap");
gtk_stack_add_named(main_stack, build_stats_page(), "stats");
gtk_stack_add_named(main_stack, build_netyears_page(), "netyears");
gtk_stack_add_named(main_stack, build_netmap_page(), "netmap");
gtk_stack_add_named(main_stack, build_hwmap_page(), "hwmap");
gtk_stack_add_named(main_stack, build_czechmap_page(), "czechmap");
gtk_stack_add_named(main_stack, build_mluvnice_page(), "mluvnice");
for (int n = 1; n <= 20; n++) {
char name[16];
g_snprintf(name, sizeof(name), "mluve%d", n);
gtk_stack_add_named(main_stack,
build_mluvnice_exercise_page(n), name);
}
gtk_stack_add_named(main_stack, build_readinglist_page(), "readinglist");
gtk_stack_add_named(main_stack, build_cetba1984_page(), "cetba1984");
gtk_stack_add_named(main_stack, build_cetba1984_quiz_page(),
"cetba1984quiz");
gtk_stack_add_named(main_stack, build_cetba1984_plot_page(),
"cetba1984dej");
gtk_stack_add_named(main_stack, build_cetba_fuks_page(), "cetbaFuks");
gtk_stack_add_named(main_stack, build_cetba_fuks_quiz_page(),
"cetbaFuksQuiz");
gtk_stack_add_named(main_stack, build_cetba_fuks_plot_page(),
"cetbaFuksDej");
gtk_stack_add_named(main_stack, build_hw_unit1_page(), "hwunit1");
gtk_stack_add_named(main_stack, build_hw_unit1_exercise_page(), "hwex1");
gtk_stack_add_named(main_stack, build_hw_unit2_page(), "hwunit2");
gtk_stack_add_named(main_stack, build_hw_unit2_exercise_page(), "hwex2");
gtk_stack_add_named(main_stack, build_hw_unit3_page(), "hwunit3");
gtk_stack_add_named(main_stack, build_hw_unit3_exercise_page(), "hwex3");
{
typedef GtkWidget *(*NetBuilder)(void);
static const struct {
const char *unit_name;
const char *ex_name;
NetBuilder build_unit;
NetBuilder build_ex;
} net_pages[] = {
{"netunit1", "netex1", build_net_unit1_page, build_net_exercise_page},
{"netunit2", "netex2", build_net_unit2_page, build_net_unit2_exercise_page},
{"netunit3", "netex3", build_net_unit3_page, build_net_unit3_exercise_page},
{"netunit4", "netex4", build_net_unit4_page, build_net_unit4_exercise_page},
{"netunit5", "netex5", build_net_unit5_page, build_net_unit5_exercise_page},
{"netunit6", "netex6", build_net_unit6_page, build_net_unit6_exercise_page},
{"netunit7", "netex7", build_net_unit7_page, build_net_unit7_exercise_page},
{"netunit8", "netex8", build_net_unit8_page, build_net_unit8_exercise_page},
{"netunit9", "netex9", build_net_unit9_page, build_net_unit9_exercise_page},
{"netunit10", "netex10", build_net_unit10_page, build_net_unit10_exercise_page},
{"netunit11", "netex11", build_net_unit11_page, build_net_unit11_exercise_page},
{"netunit12", "netex12", build_net_unit12_page, build_net_unit12_exercise_page},
{"netunit13", "netex13", build_net_unit13_page, build_net_unit13_exercise_page},
{"netunit14", "netex14", build_net_unit14_page, build_net_unit14_exercise_page},
{"netunit15", "netex15", build_net_unit15_page, build_net_unit15_exercise_page},
{"netunit16", "netex16", build_net_unit16_page, build_net_unit16_exercise_page},
{"netunit17", "netex17", build_net_unit17_page, build_net_unit17_exercise_page},
{"netunit18", "netex18", build_net_unit18_page, build_net_unit18_exercise_page},
{"netunit19", "netex19", build_net_unit19_page, build_net_unit19_exercise_page},
{"netunit20", "netex20", build_net_unit20_page, build_net_unit20_exercise_page},
{"netunit21", "netex21", build_net_unit21_page, build_net_unit21_exercise_page},
{"netunit22", "netex22", build_net_unit22_page, build_net_unit22_exercise_page},
{"netunit23", "netex23", build_net_unit23_page, build_net_unit23_exercise_page},
{"netunit24", "netex24", build_net_unit24_page, build_net_unit24_exercise_page},
{"netunit25", "netex25", build_net_unit25_page, build_net_unit25_exercise_page},
{"netunit26", "netex26", build_net_unit26_page, build_net_unit26_exercise_page},
{"netunit27", "netex27", build_net_unit27_page, build_net_unit27_exercise_page},
};
for (guint i = 0; i < G_N_ELEMENTS(net_pages); i++) {
gtk_stack_add_named(main_stack, net_pages[i].build_unit(),
net_pages[i].unit_name);
gtk_stack_add_named(main_stack, net_pages[i].build_ex(),
net_pages[i].ex_name);
}
}
for (int i = 0; i < NUM_UNLOCKED; i++) {
UnitCtx *u = &units[i];
GtkWidget *map = build_unit_page(u);
if (map)
gtk_stack_add_named(main_stack, map, u->page);
for (int n = 1; n <= u->n_ex; n++) {
GtkWidget *page = build_exercise_page(u, n);
char name[16];
if (!page)
continue;
g_snprintf(name, sizeof(name), "%se%d", u->ex_tag, n);
gtk_stack_add_named(main_stack, page, name);
}
if (u->has_branch && u->branch_target) {
GtkWidget *branch = build_translate(u);
if (branch)
gtk_stack_add_named(main_stack, branch, u->branch_target);
}
}
{
const char *start = g_getenv("MATURITA_PAGE");
const char *size = g_getenv("MATURITA_SIZE");
int w, h;
if (start && *start)
gtk_stack_set_visible_child_name(main_stack, start);
else
gtk_stack_set_visible_child_name(main_stack, "welcome");
if (size && sscanf(size, "%dx%d", &w, &h) == 2)
gtk_window_set_default_size(window, w, h);
}
refresh_completion_ui();
keys = gtk_event_controller_key_new();
gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(keys),
GTK_PHASE_CAPTURE);
g_signal_connect(keys, "key-pressed",
G_CALLBACK(on_window_key_pressed), window);
gtk_widget_add_controller(GTK_WIDGET(window), GTK_EVENT_CONTROLLER(keys));
apply_theme();
apply_language();
gtk_widget_add_tick_callback(GTK_WIDGET(window), ui_scale_tick, NULL, NULL);
update_init();
gtk_window_present(window);
if (g_getenv("MATURITA_SETTINGS"))
g_timeout_add(350, open_settings_later, NULL);
if (g_getenv("MATURITA_SMOKE"))
g_timeout_add(200, run_smoke_test, NULL);
if (g_getenv("MATURITA_SHOT")) {
static int shot_tries;
shot_tries = 0;
g_timeout_add(g_getenv("MATURITA_SETTINGS") ? 700 : 400,
shot_later, &shot_tries);
}
}
int main(int argc, char **argv) {
GtkApplication *app;
int status;
/* Resolve style.css / icons next to the binary (or AppImage / .app) and
* point progress files at a writable location before any I/O. */
setup_portable_paths();
/* Must run before GtkApplication so Wayland can resolve our .desktop
* file / icons via the application id. */
setup_portable_share_dir();
app = gtk_application_new(APP_ID, G_APPLICATION_DEFAULT_FLAGS);
g_signal_connect(app, "activate", G_CALLBACK(activate), NULL);
status = g_application_run(G_APPLICATION(app), argc, argv);
clear_app_icon_textures();
g_object_unref(app);
return status;
}

1238
src/maturita.h Normal file

File diff suppressed because it is too large Load diff

796
src/mluvnice.c Normal file
View file

@ -0,0 +1,796 @@
#include "maturita.h"
#define MLUV_N_EX 20
#define MLUV_MAX_BLANK 5
#define MLUV_MAX_OPT 5
typedef struct {
const char *prompt;
const char *shown;
int nblank;
const char *hint[MLUV_MAX_BLANK];
const char *answers[MLUV_MAX_BLANK];
} MluvItem;
typedef struct {
const char *prompt;
const char *solution;
} MluvRevealItem;
typedef struct {
const MluvItem *items;
int n;
UnitCtx *unit;
int ex_num;
GtkWidget *feedback;
GtkWidget **entries;
GtkWidget **reveal;
} MluvCtx;
typedef struct {
const MluvRevealItem *items;
int n;
UnitCtx *unit;
int ex_num;
GtkWidget *feedback;
GtkWidget **sol;
} MluvRevealCtx;
typedef struct {
const ChoiceQ *qs;
const char **expls;
int n;
UnitCtx *unit;
int ex_num;
GtkWidget *feedback;
GtkToggleButton **toggles;
GtkWidget **expl_labels;
} MluvChoiceCtx;
static UnitCtx mluv_unit;
static const char *mluv_names[MLUV_N_EX] = {
"i/y", "Velká písmena", "Slovní druhy", "Podst. jména", "Slovesa",
"Slovotvorba", "Větné členy", "Vedlejší věty", "Synonyma", "Antonyma",
"s/z", "Najdi chybu", "Tvary", "Test A–D", "Přímá řeč",
"Rozdíly", "Interpunkce", "Obrazná pojmen.", "Slohové útvary",
"Opakování",
};
void mluvnice_init(void) {
mluv_unit.title = "Mluvnice";
mluv_unit.page = "mluvnice";
mluv_unit.ex_tag = "mluv";
mluv_unit.sub_key = "Cvičení z mluvnice – styl maturita / "
"přijímačky z ČJL";
mluv_unit.back_target = "czechmap";
mluv_unit.progress_file = app_progress_mluvnice;
mluv_unit.n_ex = MLUV_N_EX;
for (int i = 0; i < MLUV_N_EX; i++)
mluv_unit.ex_names[i + 1] = mluv_names[i];
unit_load_progress(&mluv_unit);
}
void progress_for_mluvnice(ProgressSum *out) {
int d;
if (!out || mluv_unit.n_ex <= 0)
return;
d = unit_done_count(&mluv_unit);
out->total_ex += mluv_unit.n_ex;
out->open_units += 1;
out->done_ex += d;
if (d == mluv_unit.n_ex)
out->done_units++;
}
void mluvnice_refresh_ui(void) {
for (int i = 0; i < mluv_unit.n_ex; i++) {
GtkWidget *btn = mluv_unit.ex_cells[i];
if (!btn)
continue;
if (mluv_unit.done[i + 1]) {
gtk_widget_add_css_class(btn, "done");
if (mluv_unit.ex_icons[i + 1])
gtk_widget_set_visible(mluv_unit.ex_icons[i + 1], TRUE);
} else {
gtk_widget_remove_css_class(btn, "done");
if (mluv_unit.ex_icons[i + 1])
gtk_widget_set_visible(mluv_unit.ex_icons[i + 1], FALSE);
}
}
if (mluv_unit.ex_rail)
gtk_widget_queue_draw(mluv_unit.ex_rail);
}
static void mluv_check(GtkButton *button, gpointer data) {
MluvCtx *ctx = data;
int total = 0;
int ok = 0;
(void)button;
for (int i = 0; i < ctx->n; i++) {
const MluvItem *it = &ctx->items[i];
for (int j = 0; j < it->nblank; j++) {
GtkWidget *e = ctx->entries[i * MLUV_MAX_BLANK + j];
const gchar *txt = gtk_editable_get_text(GTK_EDITABLE(e));
gchar *norm = normalize_answer(txt);
gboolean good = txt && txt[0] &&
answer_accepts(norm, it->answers[j]);
g_free(norm);
answer_mark(e, good);
total++;
if (good)
ok++;
}
if (ctx->reveal[i])
gtk_widget_set_visible(ctx->reveal[i], TRUE);
}
if (total > 0 && ok == total) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
mark_done(ctx->unit, ctx->ex_num);
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_retry"));
}
}
static GtkWidget *mluv_typed_page(int ex_num, const char *title,
const char *sub, const char *note,
const MluvItem *items, int n) {
GtkWidget *body, *feedback, *check;
GtkWidget *page = ex_page_shell("mluvnice", title, sub, "check",
&body, &feedback, &check);
MluvCtx *ctx = g_new0(MluvCtx, 1);
ctx->items = items;
ctx->n = n;
ctx->unit = &mluv_unit;
ctx->ex_num = ex_num;
ctx->feedback = feedback;
ctx->entries = g_new0(GtkWidget *, n * MLUV_MAX_BLANK);
ctx->reveal = g_new0(GtkWidget *, n);
if (note) {
GtkWidget *l = gtk_label_new(note);
gtk_widget_set_halign(l, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(l), TRUE);
gtk_label_set_xalign(GTK_LABEL(l), 0.0);
gtk_widget_add_css_class(l, "hint");
gtk_widget_set_margin_bottom(l, 4);
gtk_box_append(GTK_BOX(body), l);
}
for (int i = 0; i < n; i++) {
const MluvItem *it = &items[i];
GtkWidget *card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
GtkWidget *prompt = gtk_label_new(it->prompt);
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
char *rev = g_strdup_printf("Řešení: %s", it->shown);
GtkWidget *rl = gtk_label_new(rev);
g_free(rev);
gtk_widget_add_css_class(card, "notes-card");
gtk_box_append(GTK_BOX(body), card);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(prompt), TRUE);
gtk_label_set_xalign(GTK_LABEL(prompt), 0.0);
gtk_widget_add_css_class(prompt, "ex-prompt");
gtk_box_append(GTK_BOX(card), prompt);
gtk_widget_set_halign(row, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(card), row);
for (int j = 0; j < it->nblank; j++) {
GtkWidget *e = gtk_entry_new();
gtk_editable_set_width_chars(GTK_EDITABLE(e), 18);
if (it->hint[j])
gtk_entry_set_placeholder_text(GTK_ENTRY(e), it->hint[j]);
gtk_box_append(GTK_BOX(row), e);
ctx->entries[i * MLUV_MAX_BLANK + j] = e;
}
gtk_widget_set_halign(rl, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(rl), TRUE);
gtk_label_set_xalign(GTK_LABEL(rl), 0.0);
gtk_widget_add_css_class(rl, "meaning");
gtk_widget_set_visible(rl, FALSE);
gtk_box_append(GTK_BOX(card), rl);
ctx->reveal[i] = rl;
}
g_signal_connect(check, "clicked", G_CALLBACK(mluv_check), ctx);
return page;
}
static void mluv_reveal_check(GtkButton *button, gpointer data) {
MluvRevealCtx *ctx = data;
(void)button;
for (int i = 0; i < ctx->n; i++)
gtk_widget_set_visible(ctx->sol[i], TRUE);
set_feedback(ctx->feedback, TRUE, "Zobrazili jste si vzorové řešení.");
mark_done(ctx->unit, ctx->ex_num);
}
static GtkWidget *mluv_reveal_page(int ex_num, const char *title,
const char *sub, const char *note,
const MluvRevealItem *items, int n) {
GtkWidget *body, *feedback, *check;
GtkWidget *page = ex_page_shell("mluvnice", title, sub,
"Zobrazit řešení",
&body, &feedback, &check);
MluvRevealCtx *ctx = g_new0(MluvRevealCtx, 1);
ctx->items = items;
ctx->n = n;
ctx->unit = &mluv_unit;
ctx->ex_num = ex_num;
ctx->feedback = feedback;
ctx->sol = g_new0(GtkWidget *, n);
if (note) {
GtkWidget *l = gtk_label_new(note);
gtk_widget_set_halign(l, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(l), TRUE);
gtk_label_set_xalign(GTK_LABEL(l), 0.0);
gtk_widget_add_css_class(l, "hint");
gtk_widget_set_margin_bottom(l, 4);
gtk_box_append(GTK_BOX(body), l);
}
for (int i = 0; i < n; i++) {
GtkWidget *card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
GtkWidget *prompt = gtk_label_new(items[i].prompt);
GtkWidget *sol = gtk_label_new(items[i].solution);
gtk_widget_add_css_class(card, "notes-card");
gtk_box_append(GTK_BOX(body), card);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(prompt), TRUE);
gtk_label_set_xalign(GTK_LABEL(prompt), 0.0);
gtk_widget_add_css_class(prompt, "ex-prompt");
gtk_box_append(GTK_BOX(card), prompt);
gtk_widget_set_halign(sol, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(sol), TRUE);
gtk_label_set_xalign(GTK_LABEL(sol), 0.0);
gtk_widget_add_css_class(sol, "meaning");
gtk_widget_set_visible(sol, FALSE);
gtk_box_append(GTK_BOX(card), sol);
ctx->sol[i] = sol;
}
g_signal_connect(check, "clicked", G_CALLBACK(mluv_reveal_check), ctx);
return page;
}
static void mluv_choice_check(GtkButton *button, gpointer data) {
MluvChoiceCtx *ctx = data;
int ok = 0;
(void)button;
for (int i = 0; i < ctx->n; i++) {
const ChoiceQ *q = &ctx->qs[i];
int active = -1;
for (int o = 0; o < q->n_options; o++) {
GtkToggleButton *tb = ctx->toggles[i * MLUV_MAX_OPT + o];
gtk_widget_remove_css_class(GTK_WIDGET(tb), "ok");
gtk_widget_remove_css_class(GTK_WIDGET(tb), "wrong");
gtk_widget_set_sensitive(GTK_WIDGET(tb), FALSE);
if (gtk_toggle_button_get_active(tb))
active = o;
}
if (active == q->correct) {
ok++;
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * MLUV_MAX_OPT + active]), "ok");
} else if (active >= 0) {
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * MLUV_MAX_OPT + active]), "wrong");
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * MLUV_MAX_OPT + q->correct]), "ok");
} else {
gtk_widget_add_css_class(
GTK_WIDGET(ctx->toggles[i * MLUV_MAX_OPT + q->correct]), "ok");
}
if (ctx->expl_labels && ctx->expl_labels[i])
gtk_widget_set_visible(ctx->expl_labels[i], TRUE);
}
if (ok == ctx->n) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
mark_done(ctx->unit, ctx->ex_num);
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_retry_short"));
}
}
static GtkWidget *mluv_choice_page(int ex_num, const char *title,
const char *sub, const char *note,
const ChoiceQ *qs, const char **expls,
int n) {
GtkWidget *body, *feedback, *check;
GtkWidget *page = ex_page_shell("mluvnice", title, sub, "check",
&body, &feedback, &check);
MluvChoiceCtx *ctx = g_new0(MluvChoiceCtx, 1);
ctx->qs = qs;
ctx->expls = expls;
ctx->n = n;
ctx->unit = &mluv_unit;
ctx->ex_num = ex_num;
ctx->feedback = feedback;
ctx->toggles = g_new0(GtkToggleButton *, n * MLUV_MAX_OPT);
ctx->expl_labels = g_new0(GtkWidget *, n);
if (note) {
GtkWidget *l = gtk_label_new(note);
gtk_widget_set_halign(l, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(l), TRUE);
gtk_label_set_xalign(GTK_LABEL(l), 0.0);
gtk_widget_add_css_class(l, "hint");
gtk_widget_set_margin_bottom(l, 4);
gtk_box_append(GTK_BOX(body), l);
}
for (int i = 0; i < n; i++) {
GtkWidget *card = mcq_append_question(
body, i + 1, &qs[i], &ctx->toggles[i * MLUV_MAX_OPT]);
if (expls && expls[i]) {
GtkWidget *e = gtk_label_new(expls[i]);
gtk_widget_set_halign(e, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(e), TRUE);
gtk_label_set_xalign(GTK_LABEL(e), 0.0);
gtk_widget_add_css_class(e, "meaning");
gtk_widget_set_visible(e, FALSE);
gtk_box_append(GTK_BOX(card), e);
ctx->expl_labels[i] = e;
}
}
g_signal_connect(check, "clicked", G_CALLBACK(mluv_choice_check), ctx);
return page;
}
/* ---- Exercise content --------------------------------------------- */
static const MluvItem e1_items[] = {
{"Stará bab_čka sed_la na lav_čce.", "babička, seděla, lavičce", 3,
{"i/í/y/ý", "i/í/y/ý", "i/í/y/ý"},
{"babička", "seděla|sedla", "lavičce"}},
{"Na obloze sv_tily hv_zdy.", "svítily, hvězdy", 2,
{"i/í/y/ý", "i/í/y/ý"},
{"svítily", "hvězdy"}},
{"M_sl_m, že m_š pravdu.", "Myslím, myš", 2,
{"i/í/y/ý", "i/í/y/ý"},
{"Myslím", "myš"}},
{"V_soký muž nes_l těžký kuf_r.", "Vysoký, nesl, kufr", 3,
{"i/í/y/ý", "i/í/y/ý", "i/í/y/ý"},
{"Vysoký", "nesl", "kufr"}},
{"Ryb_ář čekal cel_ den u řek_.", "Rybář, celý, řeky", 3,
{"i/í/y/ý", "i/í/y/ý", "i/í/y/ý"},
{"Rybář", "celý", "řeky"}},
};
static const ChoiceQ e2_qs[] = {
{"Narodil se v ______ v malé vesnici.",
{"Čechách", "čechách"}, 2, 0},
{"Přečetl jsem román od ______.",
{"Boženy Němcové", "boženy němcové"}, 2, 0},
{"Jedeme na ______.", {"Moravu", "moravu"}, 2, 0},
{"V ______ žije přes milion lidí.", {"Praze", "praze"}, 2, 0},
{"Navštívili jsme ______.",
{"Národní muzeum", "národní muzeum"}, 2, 0},
};
static const MluvItem e3_items[] = {
{"Krásný den se chýlil ke konci. — slovo „Krásný“",
"přídavné jméno", 1, {"slovní druh"},
{"přídavné jméno|přídavné jmeno|adjektivum"}},
{"Šel rychle domů. — slovo „rychle“",
"příslovce", 1, {"slovní druh"},
{"příslovce|adverbium"}},
{"Sedím před školou. — slovo „před“",
"předložka", 1, {"slovní druh"},
{"předložka|prepozice"}},
{"To je jeho kniha. — slovo „jeho“",
"zájmeno", 1, {"slovní druh"},
{"zájmeno|zájmeno|pronomen"}},
{"Ach, to je škoda! — slovo „Ach“",
"citoslovce", 1, {"slovní druh"},
{"citoslovce|interjekce"}},
{"Přišel jsem, ale bylo pozdě. — slovo „ale“",
"spojka", 1, {"slovní druh"},
{"spojka|konjunkce"}},
{"Napočítal jsem pět jablek. — slovo „pět“",
"číslovka", 1, {"slovní druh"},
{"číslovka|cislovka|numerale"}},
{"Učitel vysvětloval látku. — slovo „Učitel“",
"podstatné jméno", 1, {"slovní druh"},
{"podstatné jméno|podstatné jmeno|substantivum"}},
};
static const MluvItem e4_items[] = {
{"Kniha leží na stole. — slovo „stole“ (stůl)",
"mužský rod, jednotné číslo, 6. pád (lokál)", 3,
{"rod", "číslo", "pád"},
{"mužský|mužský rod|maskulinum",
"jednotné|jednotné číslo|singulár",
"6|lokál|šestý|6. pád"}},
{"Povídám si s kamarády. — slovo „kamarády“ (kamarád)",
"mužský rod, množné číslo, 7. pád (instrumentál)", 3,
{"rod", "číslo", "pád"},
{"mužský|mužský rod|maskulinum",
"množné|množné číslo|plurál",
"7|instrumentál|sedmý|7. pád"}},
{"Vidím krásnou horu. — slovo „horu“ (hora)",
"ženský rod, jednotné číslo, 4. pád (akuzativ)", 3,
{"rod", "číslo", "pád"},
{"ženský|ženský rod|femininum",
"jednotné|jednotné číslo|singulár",
"4|akuzativ|čtvrtý|4. pád"}},
{"To je dům mého souseda. — slovo „souseda“ (soused)",
"mužský rod, jednotné číslo, 2. pád (genitiv)", 3,
{"rod", "číslo", "pád"},
{"mužský|mužský rod|maskulinum",
"jednotné|jednotné číslo|singulár",
"2|genitiv|druhý|2. pád"}},
{"Děti si hrají na zahradě. — slovo „Děti“ (dítě)",
"střední rod, množné číslo, 1. pád (nominativ)", 3,
{"rod", "číslo", "pád"},
{"střední|střední rod|neutrum",
"množné|množné číslo|plurál",
"1|nominativ|první|1. pád"}},
};
static const MluvItem e5_items[] = {
{"čteme", "1. osoba, množné číslo, přítomný čas, oznamovací způsob", 4,
{"osoba", "číslo", "čas", "způsob"},
{"1|1.|první|první osoba|1. osoba",
"množné|množné číslo|plurál",
"přítomný|přítomný čas|přítomný čas",
"oznamovací|oznamovací způsob|indikativ"}},
{"běžel", "3. osoba, jednotné číslo, minulý čas, oznamovací způsob", 4,
{"osoba", "číslo", "čas", "způsob"},
{"3|3.|třetí|třetí osoba|3. osoba",
"jednotné|jednotné číslo|singulár",
"minulý|minulý čas|minulý čas",
"oznamovací|oznamovací způsob|indikativ"}},
{"přečtěte",
"2. osoba, množné číslo, rozkazovací způsob (čas se neurčuje)", 4,
{"osoba", "číslo", "čas", "způsob"},
{"2|2.|druhý|druhá osoba|2. osoba",
"množné|množné číslo|plurál",
"neurčitý|nestanovený|neurčený",
"rozkazovací|rozkazovací způsob|imperativ"}},
{"by zpíval",
"3. osoba, jednotné číslo, podmiňovací způsob přítomný", 4,
{"osoba", "číslo", "čas", "způsob"},
{"3|3.|třetí|třetí osoba|3. osoba",
"jednotné|jednotné číslo|singulár",
"neurčitý|nestanovený|přítomný",
"podmiňovací|podmiňovací způsob|kondicionál"}},
{"budou pracovat",
"3. osoba, množné číslo, budoucí čas, oznamovací způsob", 4,
{"osoba", "číslo", "čas", "způsob"},
{"3|3.|třetí|třetí osoba|3. osoba",
"množné|množné číslo|plurál",
"budoucí|budoucí čas|futurum",
"oznamovací|oznamovací způsob|indikativ"}},
};
static const MluvRevealItem e6_items[] = {
{"napsali", "na- / ps- / -a- / -li"},
{"přečíst", "pře- / číst"},
{"zahradník", "za- / hrad- / -ník / -Ø"},
{"nepřátelský", "ne- / přátel- / -ský / -Ø"},
{"výběr", "vý- / bér- / -Ø / -Ø"},
};
static const MluvItem e7_items[] = {
{"Malá holčička plakala. — větný člen „Malá holčička“",
"podmět", 1, {"větný člen"}, {"podmět|subjekt"}},
{"Otec pracuje v garáži. — větný člen „v garáži“",
"příslovečné určení místa", 1, {"větný člen"},
{"příslovečné určení místa|příslovečné určení místa (PU místa)|"
"příslovečné určení|adverbiále místa"}},
{"Sestra je učitelka. — větný člen „je učitelka“",
"přísudek jmenný se sponou", 1, {"větný člen"},
{"přísudek jmenný se sponou|přísudek jmenný|jmenný přísudek se sponou|"
"přísudek se sponou"}},
{"Dal jsem knihu bratrovi. — větný člen „knihu“",
"předmět", 1, {"větný člen"}, {"předmět|objekt"}},
{"Přišel unavený. — větný člen „unavený“",
"doplněk", 1, {"větný člen"}, {"doplněk"}},
{"Pomalu šel domů. — větný člen „Pomalu“",
"příslovečné určení způsobu", 1, {"větný člen"},
{"příslovečné určení způsobu|příslovečné určení způsobu (PU způsobu)|"
"příslovečné určení|adverbiále způsobu"}},
};
static const MluvItem e8_items[] = {
{"Věřím, že uspěješ.", "vedlejší věta předmětná", 1, {"druh vedlejší věty"},
{"vedlejší věta předmětná|věta předmětná|předmětná"}},
{"Udělám to, jak budu moci.", "vedlejší věta příslovečná způsobová", 1,
{"druh vedlejší věty"},
{"vedlejší věta příslovečná způsobová|příslovečná způsobová|způsobová"}},
{"Přišel, protože chtěl pomoci.", "vedlejší věta příslovečná příčinná", 1,
{"druh vedlejší věty"},
{"vedlejší věta příslovečná příčinná|příslovečná příčinná|příčinná"}},
{"Řekni mi, kde bydlíš.", "vedlejší věta předmětná", 1,
{"druh vedlejší věty"},
{"vedlejší věta předmětná|věta předmětná|předmětná"}},
{"Byl tak unavený, že usnul okamžitě.",
"vedlejší věta příslovečná následková", 1, {"druh vedlejší věty"},
{"vedlejší věta příslovečná následková|příslovečná následková|následková"}},
};
static const MluvItem e9_items[] = {
{"rychlý", "hbitý, svižný", 1, {"synonymum"},
{"hbitý|svižný|rychlý|čilý|bystrý|prudký"}},
{"smutný", "nešťastný, zarmoucený", 1, {"synonymum"},
{"nešťastný|zarmoucený|tesklivý|skličený|žalostný"}},
{"začít", "zahájit, započít", 1, {"synonymum"},
{"zahájit|započít|začínat|rozpoutat|zahajovat"}},
{"krásný", "nádherný, překrásný", 1, {"synonymum"},
{"nádherný|překrásný|kouzelný|půvabný|okouzlující"}},
{"mluvit", "hovořit, promlouvat", 1, {"synonymum"},
{"hovořit|promlouvat|vyprávět|říkat|povídat"}},
};
static const MluvItem e10_items[] = {
{"starý ×", "nový", 1, {"opak"}, {"nový|mladý"}},
{"začátek ×", "konec", 1, {"opak"}, {"konec|závěr"}},
{"přijít ×", "odejít", 1, {"opak"}, {"odejít|odjet|odcházet"}},
{"radost ×", "smutek", 1, {"opak"}, {"smutek|žal|zármutek"}},
{"vysoký ×", "nízký", 1, {"opak"}, {"nízký|malý"}},
};
static const MluvItem e11_items[] = {
{"(s/z)kočit dolů ze stromu", "skočit", 1, {"s-/z-"}, {"skočit"}},
{"(s/z)epsat úkol", "sepsat", 1, {"s-/z-"}, {"sepsat"}},
{"(s/z)budovat nový dům", "zbudovat", 1, {"s-/z-"}, {"zbudovat"}},
{"(s/z)vednout těžký kámen", "zvednout", 1, {"s-/z-"}, {"zvednout"}},
{"(s/z)pravit auto", "spravit", 1, {"s-/z-"}, {"spravit"}},
};
static const MluvRevealItem e12_items[] = {
{"Koupil jsem novej kabát.", "nový („novej“ je nespisovné)"},
{"Přišli jsme tam, kdy jsme měli.",
"kdy → když: „Přišli jsme tam, když jsme měli.“"},
{"Ona mě o tom neřekla nic.",
"mě → mi (dativ): „Ona mi o tom neřekla nic.“"},
{"Je to nejlepší film, který jsem viděl vůbec.",
"„vůbec nejlepší film“: „Je to vůbec nejlepší film, který jsem viděl.“"},
{"Byl jsem na nákupu a koupil chléb.",
"„Byl jsem nakupovat a koupil jsem chléb.“"},
};
static const MluvItem e13_items[] = {
{"Viděl jsem (ten starý muž) ______.", "toho starého muže", 1,
{"správný tvar"}, {"toho starého muže|toho starého muže,"}},
{"Mluvil jsem s (moje sestra) ______.", "mojí sestrou", 1,
{"správný tvar"}, {"mojí sestrou|mou sestrou|svou sestrou"}},
{"Jdu do (škola) ______.", "školy", 1, {"správný tvar"}, {"školy"}},
{"Sedím vedle (kamarád) ______.", "kamaráda", 1,
{"správný tvar"}, {"kamaráda"}},
{"Vzpomínám na (dětství) ______.", "dětství", 1,
{"správný tvar"}, {"dětství"}},
};
static const ChoiceQ e14_qs[] = {
{"Slovo „záříjový“ je:", {"správně", "chybně"}, 2, 1},
{"Které slovo je přídavné jméno?",
{"rychle", "rychlost", "rychlý", "zrychlení"}, 4, 2},
{"Věta „Petr čte knihu.“ je:",
{"souvětí", "věta jednoduchá", "věta bez podmětu", "věta zvolací"}, 4, 1},
{"Slovo „příroda“ je utvořeno:",
{"odvozením", "skládáním", "zkracováním", "přejímáním"}, 4, 0},
{"Která dvojice jsou synonyma?",
{"rychlý – pomalý", "jít – přijít", "smutný – nešťastný",
"velký – malý"}, 4, 2},
};
static const char *e14_expl[] = {
"Správně je „zářijový“ (nikoli „záříjový“).",
"„rychlý“ je přídavné jméno; „rychle“ je příslovce.",
"Věta má jen jeden přísudek („čte“), jde o větu jednoduchou.",
"„příroda“ je utvořena odvozením, není to složenina.",
"Synonyma vyjadřují stejný význam: „smutný“ a „nešťastný“.",
};
static const MluvRevealItem e15_items[] = {
{"Matka řekla: „Pojď se najíst.“",
"Matka řekla, abych přišel se najíst."},
{"Učitel řekl: „Zítra bude písemka.“",
"Učitel řekl, že zítra bude písemka."},
{"Kamarád se zeptal: „Kde bydlíš?“",
"Kamarád se zeptal, kde bydlím."},
};
static const MluvRevealItem e16_items[] = {
{"bydlet × žít",
"bydlet = mít domov někde; žít = existovat, prožívat život"},
{"říkat × vyprávět",
"říkat = sdělovat informaci; vyprávět = podrobně popisovat příběh"},
{"dívat se × vidět",
"dívat se = záměrně sledovat; vidět = vnímat zrakem (i neúmyslně)"},
{"učit × učit se",
"učit = předávat znalosti druhým; učit se = sám nabývat znalosti"},
{"přinést × donést",
"přinést = přinést sem (k mluvčímu); donést = odnést tam (od mluvčího)"},
};
static const MluvRevealItem e17_items[] = {
{"Přišel jsem domů unavený hladový a promrzlý.",
"Přišel jsem domů unavený, hladový a promrzlý."},
{"Když dorazil vlak nastoupili všichni cestující.",
"Když dorazil vlak, nastoupili všichni cestující."},
{"Myslím že dnes bude pršet.", "Myslím, že dnes bude pršet."},
{"Jana která je moje nejlepší kamarádka bydlí vedle.",
"Jana, která je moje nejlepší kamarádka, bydlí vedle."},
{"Pes štěkal ale nikdo nepřicházel.",
"Pes štěkal, ale nikdo nepřicházel."},
};
static const MluvItem e18_items[] = {
{"Slunce se usmívalo na celé město.", "personifikace", 1,
{"druh pojmenování"}, {"personifikace|zosobnění"}},
{"Je silný jako býk.", "přirovnání", 1,
{"druh pojmenování"}, {"přirovnání|komparace"}},
{"Moře hněvu ho zaplavilo.", "metafora", 1,
{"druh pojmenování"}, {"metafora"}},
{"Čekal jsem na tebe věčnost.", "hyperbola", 1,
{"druh pojmenování"}, {"hyperbola|nadsázka"}},
{"Přečetl celého Shakespeara.", "metonymie", 1,
{"druh pojmenování"}, {"metonymie"}},
};
static const MluvItem e19_items[] = {
{"Narodil se roku 1890 v Praze. Vystudoval práva a poté pracoval jako "
"úředník…", "životopis", 1, {"slohový útvar"}, {"životopis|biografie"}},
{"Byl to chlapec střední postavy, s hnědýma očima a klidným výrazem…",
"charakteristika", 1, {"slohový útvar"}, {"charakteristika"}},
{"Jednoho dne jsem šel lesem, když tu najednou jsem zaslechl podivný "
"zvuk…", "vyprávění", 1, {"slohový útvar"}, {"vyprávění"}},
{"Mnozí se ptají, zda má smysl číst knihy v době internetu. Domnívám "
"se…", "úvaha", 1, {"slohový útvar"}, {"úvaha"}},
{"Místnost byla malá, se dvěma okny na jih. U zdi stála stará skříň…",
"popis", 1, {"slohový útvar"}, {"popis"}},
};
static const ChoiceQ e20_qs[] = {
{"Které slovo není příslovce?",
{"rychle", "tam", "hezký", "brzy"}, 4, 2},
{"Ve větě „Petr dal Pavlovi knihu“ je slovo „Pavlovi“:",
{"podmět", "přísudek", "předmět", "příslovečné určení"}, 4, 2},
{"Slovo „nejhezčí“ obsahuje:",
{"předponu a příponu", "pouze předponu", "kořen a příponu",
"pouze příponu"}, 4, 0},
{"Která věta je souvětí souřadné slučovací?",
{"Přišel, protože chtěl pomoci.", "Šel domů a spal.",
"Věřím, že přijdeš.", "Udělám to, jak budeš chtít."}, 4, 1},
{"Příslovce „rychle“ je stupňováno jako:",
{"rychle – rychleji – nejrychleji",
"rychle – více rychle – nejvíce rychle",
"rychle – rychlé – nejrychlejší", "nelze stupňovat"}, 4, 0},
};
GtkWidget *build_mluvnice_exercise_page(int n) {
switch (n) {
case 1:
return mluv_typed_page(1,
"Pravopis – i/y po obojetných souhláskách",
"Doplňte i/í nebo y/ý.", NULL,
e1_items, G_N_ELEMENTS(e1_items));
case 2:
return mluv_choice_page(2, "Pravopis – velká/malá písmena",
"Vyberte správnou variantu.", NULL,
e2_qs, NULL, G_N_ELEMENTS(e2_qs));
case 3:
return mluv_typed_page(3, "Slovní druhy",
"Určete slovní druh podtržených slov.",
"Do pole napište název slovního druhu.",
e3_items, G_N_ELEMENTS(e3_items));
case 4:
return mluv_typed_page(4,
"Mluvnické kategorie – podstatná jména",
"Určete rod, číslo a pád podtržených slov.",
"Vyplňte tři pole: rod, číslo a pád.",
e4_items, G_N_ELEMENTS(e4_items));
case 5:
return mluv_typed_page(5, "Mluvnické kategorie – slovesa",
"Určete osobu, číslo, čas a způsob u těchto sloves.",
"U rozkazovacího a podmiňovacího způsobu napište do pole "
"čas „neurčitý“.",
e5_items, G_N_ELEMENTS(e5_items));
case 6:
return mluv_reveal_page(6, "Slovotvorba",
"Rozeberte slova na morfémy.",
"Určete předponu, kořen, příponu a koncovku.",
e6_items, G_N_ELEMENTS(e6_items));
case 7:
return mluv_typed_page(7, "Větné členy",
"Určete větný člen podtržených výrazů.", NULL,
e7_items, G_N_ELEMENTS(e7_items));
case 8:
return mluv_typed_page(8, "Druhy vedlejších vět",
"Určete druh vedlejší věty.", NULL,
e8_items, G_N_ELEMENTS(e8_items));
case 9:
return mluv_typed_page(9, "Synonyma",
"Ke každému slovu napište synonymum.", NULL,
e9_items, G_N_ELEMENTS(e9_items));
case 10:
return mluv_typed_page(10, "Antonyma", "Napište opak.", NULL,
e10_items, G_N_ELEMENTS(e10_items));
case 11:
return mluv_typed_page(11,
"Pravopis – s/z na začátku slova",
"Vyberte správnou předponu s-/z-.", NULL,
e11_items, G_N_ELEMENTS(e11_items));
case 12:
return mluv_reveal_page(12, "Skladba – najděte chybu",
"Každá věta obsahuje jednu chybu. Najděte ji a opravte.",
NULL, e12_items, G_N_ELEMENTS(e12_items));
case 13:
return mluv_typed_page(13, "Tvarosloví – správný tvar",
"Dejte slovo do správného tvaru.", NULL,
e13_items, G_N_ELEMENTS(e13_items));
case 14:
return mluv_choice_page(14, "Výběr ze čtyř možností",
"Vyberte správnou odpověď.", NULL,
e14_qs, e14_expl, G_N_ELEMENTS(e14_qs));
case 15:
return mluv_reveal_page(15, "Přímá a nepřímá řeč",
"Přepište přímou řeč na nepřímou.", NULL,
e15_items, G_N_ELEMENTS(e15_items));
case 16:
return mluv_reveal_page(16, "Slovní zásoba – rozdíly",
"Vysvětlete rozdíl mezi těmito dvojicemi slov.", NULL,
e16_items, G_N_ELEMENTS(e16_items));
case 17:
return mluv_reveal_page(17, "Interpunkce",
"Doplňte čárky tam, kde patří.", NULL,
e17_items, G_N_ELEMENTS(e17_items));
case 18:
return mluv_typed_page(18, "Obrazná pojmenování",
"Určete, o jaký druh obrazného pojmenování jde.", NULL,
e18_items, G_N_ELEMENTS(e18_items));
case 19:
return mluv_typed_page(19, "Stylistika – slohové útvary",
"Přiřaďte ukázku ke správnému slohovému útvaru.", NULL,
e19_items, G_N_ELEMENTS(e19_items));
case 20:
return mluv_choice_page(20, "Souhrnné opakování",
"Vyberte správnou odpověď.", NULL,
e20_qs, NULL, G_N_ELEMENTS(e20_qs));
default:
return NULL;
}
}
GtkWidget *build_mluvnice_page(void) {
return build_unit_page(&mluv_unit);
}

140
src/navigation.c Normal file
View file

@ -0,0 +1,140 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Navigation */
/* ------------------------------------------------------------------ */
void on_nav_clicked(GtkButton *button, gpointer user_data) {
const char *target = g_object_get_data(G_OBJECT(button), "target");
(void)user_data;
if (target)
gtk_stack_set_visible_child_name(main_stack, target);
}
void on_continue_clicked(GtkButton *button, gpointer user_data) {
(void)button;
(void)user_data;
gtk_stack_set_visible_child_name(main_stack, "subjects");
}
GtkWidget *make_back_button(const char *target) {
GtkWidget *back = gtk_button_new();
GtkWidget *icon = gtk_drawing_area_new();
gtk_widget_set_size_request(icon, 14, 14);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(icon), draw_back_icon,
NULL, NULL);
gtk_button_set_child(GTK_BUTTON(back), icon);
gtk_widget_add_css_class(back, "back-btn");
gtk_widget_set_valign(back, GTK_ALIGN_CENTER);
i18n_bind(back, "back", 2);
g_signal_connect_swapped(back, "state-flags-changed",
G_CALLBACK(gtk_widget_queue_draw), icon);
g_object_set_data_full(G_OBJECT(back), "target", g_strdup(target), g_free);
g_signal_connect(back, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return back;
}
GtkWidget *top_bar(const char *back_target, const char *title,
const char *subtitle) {
GtkWidget *top = gtk_center_box_new();
gtk_widget_set_margin_top(top, 2);
gtk_widget_set_margin_bottom(top, 6);
GtkWidget *back = make_back_button(back_target);
gtk_center_box_set_start_widget(GTK_CENTER_BOX(top), back);
GtkWidget *center = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_valign(center, GTK_ALIGN_CENTER);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(top), center);
GtkWidget *heading = gtk_label_new(NULL);
i18n_bind(heading, title, 0);
gtk_widget_set_halign(heading, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(heading, "roadmap-title");
gtk_box_append(GTK_BOX(center), heading);
if (subtitle) {
GtkWidget *sub = gtk_label_new(NULL);
i18n_bind(sub, subtitle, 0);
gtk_widget_set_halign(sub, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(sub, "roadmap-sub");
gtk_label_set_wrap(GTK_LABEL(sub), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(sub), 60);
gtk_box_append(GTK_BOX(center), sub);
}
return top;
}
gboolean on_window_key_pressed(GtkEventControllerKey *controller,
guint keyval, guint keycode,
GdkModifierType state,
gpointer user_data) {
GtkWindow *window = user_data;
(void)controller;
(void)keycode;
if ((keyval == GDK_KEY_q &&
(state & (GDK_SUPER_MASK | GDK_META_MASK)) != 0) ||
(keyval == GDK_KEY_F4 &&
(state & GDK_ALT_MASK) != 0)) {
GtkApplication *app = gtk_window_get_application(window);
if (app != NULL)
g_application_quit(G_APPLICATION(app));
return GDK_EVENT_STOP;
}
return GDK_EVENT_PROPAGATE;
}
/* ------------------------------------------------------------------ */
/* Welcome page */
/* ------------------------------------------------------------------ */
GtkWidget *build_welcome_page(void) {
GtkWidget *box;
GtkWidget *icon;
GtkWidget *heading;
GtkWidget *body;
GtkWidget *button;
box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_halign(box, GTK_ALIGN_CENTER);
gtk_widget_set_valign(box, GTK_ALIGN_CENTER);
gtk_widget_set_margin_top(box, 40);
gtk_widget_set_margin_bottom(box, 40);
icon = gtk_image_new_from_icon_name(ICON_NAME);
gtk_image_set_pixel_size(GTK_IMAGE(icon), 88);
gtk_widget_set_halign(icon, GTK_ALIGN_CENTER);
gtk_widget_set_margin_bottom(icon, 22);
gtk_box_append(GTK_BOX(box), icon);
heading = gtk_label_new(NULL);
welcome_heading = heading;
gtk_widget_set_halign(heading, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(heading, "heading");
gtk_box_append(GTK_BOX(box), heading);
body = gtk_label_new(NULL);
i18n_bind(body, "welcome_body", 0);
gtk_label_set_justify(GTK_LABEL(body), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(body), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(body), 46);
gtk_widget_set_margin_top(body, 10);
gtk_widget_add_css_class(body, "welcome-body");
gtk_box_append(GTK_BOX(box), body);
button = gtk_button_new();
i18n_bind(button, "continue", 1);
gtk_widget_set_halign(button, GTK_ALIGN_CENTER);
gtk_widget_set_margin_top(button, 28);
gtk_widget_add_css_class(button, "btn-primary");
gtk_box_append(GTK_BOX(box), button);
g_signal_connect(button, "clicked", G_CALLBACK(on_continue_clicked), NULL);
return box;
}

3982
src/net.c Normal file

File diff suppressed because it is too large Load diff

343
src/progress.c Normal file
View file

@ -0,0 +1,343 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Progress */
/* ------------------------------------------------------------------ */
void unit_save_progress(UnitCtx *u) {
GKeyFile *kf = g_key_file_new();
for (int i = 1; i <= MAX_UNIT_EX; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i);
g_key_file_set_boolean(kf, "done", key, u->done[i]);
}
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
g_key_file_free(kf);
return;
}
gchar *data = g_key_file_to_data(kf, NULL, NULL);
g_key_file_free(kf);
if (data) {
GError *err = NULL;
g_file_set_contents(u->progress_file, data, -1, &err);
if (err)
g_error_free(err);
g_free(data);
}
}
void unit_load_progress(UnitCtx *u) {
GKeyFile *kf = g_key_file_new();
GError *err = NULL;
if (!g_key_file_load_from_file(kf, u->progress_file, G_KEY_FILE_NONE, &err)) {
if (err)
g_error_free(err);
g_key_file_free(kf);
return;
}
for (int i = 1; i <= MAX_UNIT_EX; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i);
u->done[i] = g_key_file_get_boolean(kf, "done", key, NULL);
}
g_key_file_free(kf);
}
void load_progress(void) {
for (int i = 0; i < NUM_UNLOCKED; i++)
unit_load_progress(&units[i]);
net_load_progress();
hw_load_progress();
}
/* Redraw the exercise rails of every unit that already has a page. */
void all_rails_redraw(void) {
for (int i = 0; i < NUM_UNLOCKED; i++) {
if (units[i].ex_rail)
gtk_widget_queue_draw(units[i].ex_rail);
}
}
/* Index of the unit the student should work on next: the first unlocked one
* that is not finished yet. -1 once every open unit is complete. */
static int current_unit_index(void) {
for (int uidx = 0; uidx < NUM_UNLOCKED; uidx++) {
UnitCtx *u = &units[uidx];
if (u->n_ex <= 0)
continue;
for (int i = 1; i <= u->n_ex; i++)
if (!u->done[i])
return uidx;
}
return -1;
}
void refresh_completion_ui(void) {
int current = current_unit_index();
for (int uidx = 0; uidx < NUM_UNLOCKED; uidx++) {
UnitCtx *u = &units[uidx];
gboolean all = TRUE;
for (int i = 1; i <= u->n_ex; i++) {
if (!u->done[i]) {
all = FALSE;
break;
}
}
/* per-bubble markers */
if (u->ex_cells[0] != NULL) {
for (int i = 1; i <= u->n_ex; i++) {
GtkWidget *btn = u->ex_cells[i - 1];
if (!btn)
continue;
if (u->done[i]) {
gtk_widget_add_css_class(btn, "done");
if (u->ex_icons[i])
gtk_widget_set_visible(u->ex_icons[i], TRUE);
} else {
gtk_widget_remove_css_class(btn, "done");
if (u->ex_icons[i])
gtk_widget_set_visible(u->ex_icons[i], FALSE);
}
}
}
/* off-path branch marker */
if (u->has_branch && u->branch_cell) {
if (u->done[u->branch_ex]) {
gtk_widget_add_css_class(u->branch_cell, "done");
if (u->branch_icon)
gtk_widget_set_visible(u->branch_icon, TRUE);
} else {
gtk_widget_remove_css_class(u->branch_cell, "done");
if (u->branch_icon)
gtk_widget_set_visible(u->branch_icon, FALSE);
}
}
/* Roadmap node: finished, the one step to take next, or simply open.
* Highlighting every unlocked unit made three of them look equally
* "current" and told the student nothing. */
if (u->node) {
gboolean done = all && u->n_ex > 0;
if (done)
gtk_widget_add_css_class(u->node, "done");
else
gtk_widget_remove_css_class(u->node, "done");
if (!done && uidx == current)
gtk_widget_add_css_class(u->node, "current");
else
gtk_widget_remove_css_class(u->node, "current");
if (u->node_done_icon)
gtk_widget_set_visible(u->node_done_icon, done);
}
}
all_rails_redraw();
refresh_net_completion_ui();
refresh_hw_completion_ui();
mluvnice_refresh_ui();
refresh_stats_ui();
}
void mark_done(UnitCtx *u, int n) {
if (!u || n < 1 || n > MAX_UNIT_EX)
return;
u->done[n] = TRUE;
refresh_completion_ui();
unit_save_progress(u);
}
void net_load_progress(void) {
GKeyFile *kf = g_key_file_new();
GError *err = NULL;
if (!g_key_file_load_from_file(kf, PROGRESS_NET, G_KEY_FILE_NONE, &err)) {
if (err)
g_error_free(err);
g_key_file_free(kf);
return;
}
for (int i = 0; i < NET_LESSONS; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i + 1);
net_lessons[i].done = g_key_file_get_boolean(kf, "done", key, NULL);
}
g_key_file_free(kf);
}
void net_save_progress(void) {
GKeyFile *kf = g_key_file_new();
for (int i = 0; i < NET_LESSONS; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i + 1);
g_key_file_set_boolean(kf, "done", key, net_lessons[i].done);
}
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
g_key_file_free(kf);
return;
}
gchar *data = g_key_file_to_data(kf, NULL, NULL);
g_key_file_free(kf);
if (data) {
GError *err = NULL;
g_file_set_contents(PROGRESS_NET, data, -1, &err);
if (err)
g_error_free(err);
g_free(data);
}
}
void refresh_net_completion_ui(void) {
for (int i = 0; i < NET_LESSONS; i++) {
GtkWidget *node = net_nodes[i];
GtkWidget *icon = net_lessons[i].done_icon;
if (!node)
continue;
if (net_lessons[i].done) {
gtk_widget_remove_css_class(node, "current");
gtk_widget_add_css_class(node, "done");
if (icon)
gtk_widget_set_visible(icon, TRUE);
} else {
gtk_widget_remove_css_class(node, "done");
gtk_widget_add_css_class(node, "current");
if (icon)
gtk_widget_set_visible(icon, FALSE);
}
}
}
void mark_net_done(int lesson_id) {
if (lesson_id < 0 || lesson_id >= NET_LESSONS)
return;
if (net_lessons[lesson_id].done)
return;
net_lessons[lesson_id].done = TRUE;
net_save_progress();
refresh_net_completion_ui();
refresh_stats_ui();
}
void progress_for_net(ProgressSum *out) {
out->total_ex += NET_LESSONS;
out->open_units += NET_LESSONS;
for (int i = 0; i < NET_LESSONS; i++) {
if (net_lessons[i].done) {
out->done_ex++;
out->done_units++;
}
}
}
void hw_load_progress(void) {
GKeyFile *kf = g_key_file_new();
GError *err = NULL;
if (!g_key_file_load_from_file(kf, PROGRESS_HW, G_KEY_FILE_NONE, &err)) {
if (err)
g_error_free(err);
g_key_file_free(kf);
return;
}
for (int i = 0; i < HW_LESSONS; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i + 1);
hw_lessons[i].done = g_key_file_get_boolean(kf, "done", key, NULL);
}
g_key_file_free(kf);
}
void hw_save_progress(void) {
GKeyFile *kf = g_key_file_new();
for (int i = 0; i < HW_LESSONS; i++) {
gchar key[8];
g_snprintf(key, sizeof(key), "%d", i + 1);
g_key_file_set_boolean(kf, "done", key, hw_lessons[i].done);
}
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
g_key_file_free(kf);
return;
}
gchar *data = g_key_file_to_data(kf, NULL, NULL);
g_key_file_free(kf);
if (data) {
GError *err = NULL;
g_file_set_contents(PROGRESS_HW, data, -1, &err);
if (err)
g_error_free(err);
g_free(data);
}
}
void refresh_hw_completion_ui(void) {
for (int i = 0; i < HW_LESSONS; i++) {
GtkWidget *node = hw_nodes[i];
GtkWidget *icon = hw_lessons[i].done_icon;
if (!node)
continue;
if (hw_lessons[i].done) {
gtk_widget_remove_css_class(node, "current");
gtk_widget_add_css_class(node, "done");
if (icon)
gtk_widget_set_visible(icon, TRUE);
} else {
gtk_widget_remove_css_class(node, "done");
gtk_widget_add_css_class(node, "current");
if (icon)
gtk_widget_set_visible(icon, FALSE);
}
}
}
void mark_hw_done(int lesson_id) {
if (lesson_id < 0 || lesson_id >= HW_LESSONS)
return;
if (hw_lessons[lesson_id].done)
return;
hw_lessons[lesson_id].done = TRUE;
hw_save_progress();
refresh_hw_completion_ui();
refresh_stats_ui();
}
void progress_for_hw(ProgressSum *out) {
out->total_ex += HW_LESSONS;
out->open_units += HW_LESSONS;
for (int i = 0; i < HW_LESSONS; i++) {
if (hw_lessons[i].done) {
out->done_ex++;
out->done_units++;
}
}
}

409
src/roadmap.c Normal file
View file

@ -0,0 +1,409 @@
#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_apply_layout();
road_relayout_later();
return page;
}

360
src/settings.c Normal file
View file

@ -0,0 +1,360 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Settings */
/* ------------------------------------------------------------------ */
static GtkWidget *settings_popover;
void settings_open(void) {
if (settings_popover)
gtk_popover_popup(GTK_POPOVER(settings_popover));
}
void draw_theme_swatch(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
ThemeId id = (ThemeId)GPOINTER_TO_INT(data);
ThemePalette p = theme_palette(id, app_color_mode);
Rgb base = color_from_hex(p.base);
Rgb a1 = color_from_hex(p.accent);
Rgb a2 = color_from_hex(p.accent3);
Rgb ok = color_from_hex(p.success);
Rgb accent = color_from_hex(app_theme.accent);
gboolean selected = (app_theme_id == id);
double size = MIN(width, height);
double cx = width / 2.0;
double cy = height / 2.0;
double r = size / 2.0 - (selected ? 3.0 : 1.0);
double dot = MAX(3.5, r * 0.26);
double gap = dot * 2.05;
(void)area;
if (selected) {
cairo_new_path(cr);
cairo_arc(cr, cx, cy, size / 2.0 - 0.5, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, accent.r, accent.g, accent.b);
cairo_set_line_width(cr, 2.2);
cairo_stroke(cr);
}
cairo_new_path(cr);
cairo_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, base.r, base.g, base.b);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, cx - gap, cy, dot, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, a1.r, a1.g, a1.b);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, cx, cy, dot, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, a2.r, a2.g, a2.b);
cairo_fill(cr);
cairo_new_path(cr);
cairo_arc(cr, cx + gap, cy, dot, 0.0, 2.0 * G_PI);
cairo_set_source_rgb(cr, ok.r, ok.g, ok.b);
cairo_fill(cr);
}
void on_mode_toggled(GtkToggleButton *btn, gpointer user_data) {
ColorMode mode = (ColorMode)GPOINTER_TO_INT(user_data);
if (!gtk_toggle_button_get_active(btn))
return;
if (app_color_mode == mode)
return;
app_color_mode = mode;
apply_theme();
save_settings();
}
void on_theme_toggled(GtkToggleButton *btn, gpointer user_data) {
ThemeId id = (ThemeId)GPOINTER_TO_INT(user_data);
if (!gtk_toggle_button_get_active(btn))
return;
if (app_theme_id == id)
return;
app_theme_id = id;
apply_theme();
save_settings();
}
void on_lang_toggled(GtkToggleButton *btn, gpointer user_data) {
UiLang lang = (UiLang)GPOINTER_TO_INT(user_data);
if (!gtk_toggle_button_get_active(btn))
return;
if (app_lang == lang)
return;
app_lang = lang;
apply_language();
save_settings();
}
GtkWidget *make_lang_chip(const char *label, UiLang lang,
GtkToggleButton *group) {
GtkWidget *btn = gtk_toggle_button_new_with_label(label);
gtk_widget_add_css_class(btn, "mode-chip");
gtk_widget_set_hexpand(btn, TRUE);
if (group)
gtk_toggle_button_set_group(GTK_TOGGLE_BUTTON(btn), group);
g_signal_connect(btn, "toggled", G_CALLBACK(on_lang_toggled),
GINT_TO_POINTER(lang));
return btn;
}
GtkWidget *make_mode_chip(const char *label, ColorMode mode,
GtkToggleButton *group) {
GtkWidget *btn = gtk_toggle_button_new();
i18n_bind(btn, label, 1);
gtk_widget_add_css_class(btn, "mode-chip");
gtk_widget_set_hexpand(btn, TRUE);
if (group)
gtk_toggle_button_set_group(GTK_TOGGLE_BUTTON(btn), group);
g_signal_connect(btn, "toggled", G_CALLBACK(on_mode_toggled),
GINT_TO_POINTER(mode));
return btn;
}
GtkWidget *make_theme_card(ThemeId id, GtkToggleButton *group) {
GtkWidget *btn;
GtkWidget *vbox;
GtkWidget *swatch;
GtkWidget *name;
btn = gtk_toggle_button_new();
gtk_widget_add_css_class(btn, "theme-card");
gtk_widget_set_hexpand(btn, TRUE);
if (group)
gtk_toggle_button_set_group(GTK_TOGGLE_BUTTON(btn), group);
vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
gtk_widget_set_halign(vbox, GTK_ALIGN_CENTER);
gtk_button_set_child(GTK_BUTTON(btn), vbox);
swatch = gtk_drawing_area_new();
gtk_widget_set_size_request(swatch, 44, 44);
gtk_widget_set_halign(swatch, GTK_ALIGN_CENTER);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(swatch),
draw_theme_swatch,
GINT_TO_POINTER(id), NULL);
theme_swatch_areas[id] = swatch;
gtk_box_append(GTK_BOX(vbox), swatch);
name = gtk_label_new(theme_names[id]);
gtk_widget_add_css_class(name, "theme-card-name");
gtk_widget_set_halign(name, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(vbox), name);
g_signal_connect(btn, "toggled", G_CALLBACK(on_theme_toggled),
GINT_TO_POINTER(id));
return btn;
}
void on_settings_clicked(GtkButton *button, gpointer user_data) {
GtkPopover *popover = GTK_POPOVER(user_data);
(void)button;
gtk_popover_popup(popover);
}
GtkWidget *build_stats_button(void) {
GtkWidget *btn;
GtkWidget *icon;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "settings-btn");
gtk_widget_add_css_class(btn, "flat");
i18n_bind(btn, "stats", 2);
gtk_widget_set_valign(btn, GTK_ALIGN_CENTER);
gtk_widget_set_focus_on_click(btn, FALSE);
icon = gtk_drawing_area_new();
gtk_widget_set_size_request(icon, 18, 18);
gtk_widget_set_can_target(icon, FALSE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(icon),
draw_stats_icon, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(btn), icon);
g_signal_connect_swapped(btn, "state-flags-changed",
G_CALLBACK(gtk_widget_queue_draw), icon);
g_signal_connect(btn, "clicked", G_CALLBACK(on_stats_clicked), NULL);
return btn;
}
GtkWidget *build_settings_button(void) {
GtkWidget *btn;
GtkWidget *icon;
GtkWidget *popover;
GtkWidget *box;
GtkWidget *title;
GtkWidget *label;
GtkWidget *mode_row;
GtkWidget *lang_row;
GtkWidget *theme_grid;
GtkWidget *dark_btn;
GtkWidget *light_btn;
GtkWidget *cs_btn;
GtkWidget *en_btn;
GtkWidget *first_theme = NULL;
int i;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "settings-btn");
gtk_widget_add_css_class(btn, "flat");
i18n_bind(btn, "settings", 2);
gtk_widget_set_valign(btn, GTK_ALIGN_CENTER);
gtk_widget_set_focus_on_click(btn, FALSE);
icon = gtk_drawing_area_new();
gtk_widget_set_size_request(icon, 18, 18);
gtk_widget_set_can_target(icon, FALSE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(icon),
draw_settings_icon, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(btn), icon);
g_signal_connect_swapped(btn, "state-flags-changed",
G_CALLBACK(gtk_widget_queue_draw), icon);
popover = gtk_popover_new();
settings_popover = popover;
gtk_widget_add_css_class(popover, "settings-popover");
gtk_popover_set_has_arrow(GTK_POPOVER(popover), TRUE);
gtk_popover_set_position(GTK_POPOVER(popover), GTK_POS_BOTTOM);
gtk_widget_set_parent(popover, btn);
g_signal_connect_swapped(btn, "destroy",
G_CALLBACK(gtk_widget_unparent), popover);
g_signal_connect(btn, "clicked", G_CALLBACK(on_settings_clicked), popover);
box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 14);
gtk_widget_add_css_class(box, "settings-box");
title = gtk_label_new(NULL);
i18n_bind(title, "settings_title", 0);
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_add_css_class(title, "settings-title");
gtk_box_append(GTK_BOX(box), title);
label = gtk_label_new(NULL);
i18n_bind(label, "mode", 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "settings-label");
gtk_widget_set_margin_top(label, 4);
gtk_box_append(GTK_BOX(box), label);
mode_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_add_css_class(mode_row, "settings-seg");
gtk_box_append(GTK_BOX(box), mode_row);
dark_btn = make_mode_chip("mode_dark", MODE_DARK, NULL);
light_btn = make_mode_chip("mode_light", MODE_LIGHT,
GTK_TOGGLE_BUTTON(dark_btn));
g_signal_handlers_block_matched(dark_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_mode_toggled, NULL);
g_signal_handlers_block_matched(light_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_mode_toggled, NULL);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(dark_btn),
app_color_mode == MODE_DARK);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(light_btn),
app_color_mode == MODE_LIGHT);
g_signal_handlers_unblock_matched(dark_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_mode_toggled, NULL);
g_signal_handlers_unblock_matched(light_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_mode_toggled, NULL);
gtk_box_append(GTK_BOX(mode_row), dark_btn);
gtk_box_append(GTK_BOX(mode_row), light_btn);
label = gtk_label_new(NULL);
i18n_bind(label, "theme", 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "settings-label");
gtk_widget_set_margin_top(label, 2);
gtk_box_append(GTK_BOX(box), label);
theme_grid = gtk_flow_box_new();
gtk_widget_add_css_class(theme_grid, "theme-grid");
gtk_flow_box_set_selection_mode(GTK_FLOW_BOX(theme_grid),
GTK_SELECTION_NONE);
gtk_flow_box_set_min_children_per_line(GTK_FLOW_BOX(theme_grid), 3);
gtk_flow_box_set_max_children_per_line(GTK_FLOW_BOX(theme_grid), 3);
gtk_flow_box_set_column_spacing(GTK_FLOW_BOX(theme_grid), 8);
gtk_flow_box_set_row_spacing(GTK_FLOW_BOX(theme_grid), 8);
gtk_flow_box_set_homogeneous(GTK_FLOW_BOX(theme_grid), TRUE);
gtk_widget_set_hexpand(theme_grid, TRUE);
gtk_box_append(GTK_BOX(box), theme_grid);
for (i = 0; i < THEME_COUNT; i++) {
GtkWidget *card = make_theme_card(
(ThemeId)i,
first_theme ? GTK_TOGGLE_BUTTON(first_theme) : NULL);
if (!first_theme)
first_theme = card;
if (app_theme_id == (ThemeId)i) {
g_signal_handlers_block_matched(card, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_theme_toggled, NULL);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(card), TRUE);
g_signal_handlers_unblock_matched(card, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_theme_toggled, NULL);
}
gtk_flow_box_insert(GTK_FLOW_BOX(theme_grid), card, -1);
}
label = gtk_label_new(NULL);
i18n_bind(label, "language", 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "settings-label");
gtk_widget_set_margin_top(label, 4);
gtk_box_append(GTK_BOX(box), label);
lang_row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4);
gtk_widget_add_css_class(lang_row, "settings-seg");
gtk_box_append(GTK_BOX(box), lang_row);
cs_btn = make_lang_chip("Čeština", LANG_CS, NULL);
en_btn = make_lang_chip("English", LANG_EN, GTK_TOGGLE_BUTTON(cs_btn));
g_signal_handlers_block_matched(cs_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_lang_toggled, NULL);
g_signal_handlers_block_matched(en_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_lang_toggled, NULL);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(cs_btn),
app_lang == LANG_CS);
gtk_toggle_button_set_active(GTK_TOGGLE_BUTTON(en_btn),
app_lang == LANG_EN);
g_signal_handlers_unblock_matched(cs_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_lang_toggled, NULL);
g_signal_handlers_unblock_matched(en_btn, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_lang_toggled, NULL);
gtk_box_append(GTK_BOX(lang_row), cs_btn);
gtk_box_append(GTK_BOX(lang_row), en_btn);
label = gtk_label_new(NULL);
i18n_bind(label, "updates", 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "settings-label");
gtk_widget_set_margin_top(label, 4);
gtk_box_append(GTK_BOX(box), label);
gtk_box_append(GTK_BOX(box), build_update_box());
{
GtkWidget *sw = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sw),
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
/* Tall enough for every section, including updates at the bottom; GTK
* still shrinks the popover to fit the window and scrolls the rest. */
gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(sw), 820);
gtk_scrolled_window_set_has_frame(GTK_SCROLLED_WINDOW(sw), FALSE);
gtk_scrolled_window_set_propagate_natural_width(GTK_SCROLLED_WINDOW(sw), TRUE);
gtk_scrolled_window_set_propagate_natural_height(GTK_SCROLLED_WINDOW(sw), TRUE);
gtk_widget_set_hexpand(sw, FALSE);
gtk_widget_set_vexpand(sw, FALSE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(sw), box);
gtk_popover_set_child(GTK_POPOVER(popover), sw);
}
return btn;
}

432
src/stats.c Normal file
View file

@ -0,0 +1,432 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Statistics */
/* ------------------------------------------------------------------ */
GtkWidget *make_back_button(const char *target);
void on_nav_clicked(GtkButton *button, gpointer user_data);
StatsUi stats_ui;
char *stats_return_page;
int unit_done_count(const UnitCtx *u) {
int n = 0;
if (!u)
return 0;
for (int i = 1; i <= u->n_ex; i++) {
if (u->done[i])
n++;
}
return n;
}
/* Sum progress over units[first .. first+n-1]. Locked placeholder units and
* the finish node (no playable exercises) are ignored. */
void progress_for_units(int first, int n, ProgressSum *out) {
for (int j = 0; j < n; j++) {
UnitCtx *u;
int d;
if (first < 0 || first + j >= NUM_UNITS)
continue;
u = &units[first + j];
if (!u->unlocked || u->n_ex <= 0)
continue;
d = unit_done_count(u);
out->done_ex += d;
out->total_ex += u->n_ex;
out->open_units++;
if (d == u->n_ex)
out->done_units++;
}
}
/* GTK gives the progress node a minimum width, so a fraction of zero would
* still paint a short stub of accent colour. Mark those bars so the
* stylesheet can hide the fill entirely. */
static void stats_set_fraction(GtkWidget *bar, double fraction) {
gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(bar), fraction);
if (fraction > 0.0)
gtk_widget_remove_css_class(bar, "empty");
else
gtk_widget_add_css_class(bar, "empty");
}
void refresh_stats_ui(void) {
ProgressSum all = {0};
int pct;
char *tmp;
if (!stats_ui.ex_value)
return;
for (int s = 0; s < NUM_SUBJECTS; s++) {
if (s == NET_SUBJ)
progress_for_net(&all);
else if (s == HW_SUBJ)
progress_for_hw(&all);
else if (s == CZ_SUBJ)
progress_for_mluvnice(&all);
else
progress_for_units(sub_unit_start[s], sub_unit_count[s], &all);
}
pct = all.total_ex > 0 ? (all.done_ex * 100) / all.total_ex : 0;
tmp = g_strdup_printf(tr("stats_ex_fmt"), all.done_ex, all.total_ex);
gtk_label_set_text(GTK_LABEL(stats_ui.ex_value), tmp);
g_free(tmp);
tmp = g_strdup_printf(tr("stats_pct_fmt"), pct);
gtk_label_set_text(GTK_LABEL(stats_ui.pct_value), tmp);
g_free(tmp);
tmp = g_strdup_printf(tr("stats_units_fmt"), all.done_units,
all.open_units);
gtk_label_set_text(GTK_LABEL(stats_ui.units_value), tmp);
g_free(tmp);
stats_set_fraction(stats_ui.overall_bar,
all.total_ex > 0
? (double)all.done_ex / (double)all.total_ex
: 0.0);
for (int s = 0; s < NUM_SUBJECTS; s++) {
GtkWidget *count = stats_ui.subj[s].count;
GtkWidget *bar = stats_ui.subj[s].bar;
ProgressSum sp = {0};
gboolean has_content = (s == NET_SUBJ) || (s == HW_SUBJ)
|| (s == CZ_SUBJ) || sub_unit_count[s] > 0;
if (!count || !bar)
continue;
if (s == NET_SUBJ)
progress_for_net(&sp);
else if (s == HW_SUBJ)
progress_for_hw(&sp);
else if (s == CZ_SUBJ)
progress_for_mluvnice(&sp);
else
progress_for_units(sub_unit_start[s], sub_unit_count[s], &sp);
if (has_content) {
tmp = g_strdup_printf(tr("stats_ex_fmt"), sp.done_ex, sp.total_ex);
gtk_label_set_text(GTK_LABEL(count), tmp);
g_free(tmp);
stats_set_fraction(bar,
sp.total_ex > 0
? (double)sp.done_ex / (double)sp.total_ex
: 0.0);
} else {
gtk_label_set_text(GTK_LABEL(count), tr("stats_locked"));
stats_set_fraction(bar, 0.0);
}
}
for (int i = 0; i < NUM_UNITS; i++) {
UnitCtx *u = &units[i];
GtkWidget *count = stats_ui.unit_count[i];
GtkWidget *badge = stats_ui.unit_badge[i];
GtkWidget *bar = stats_ui.unit_bar[i];
GtkWidget *row = stats_ui.unit_row[i];
gboolean unlocked = u->unlocked && u->n_ex > 0;
int d = unlocked ? unit_done_count(u) : 0;
gboolean complete = unlocked && d == u->n_ex;
if (!count || !bar || !row)
continue;
gtk_widget_remove_css_class(row, "done");
gtk_widget_remove_css_class(row, "locked");
gtk_widget_remove_css_class(bar, "done");
if (!unlocked) {
gtk_widget_add_css_class(row, "locked");
gtk_label_set_text(GTK_LABEL(count), tr("stats_locked"));
stats_set_fraction(bar, 0.0);
if (badge)
gtk_widget_set_visible(badge, FALSE);
continue;
}
tmp = g_strdup_printf(tr("stats_ex_fmt"), d, u->n_ex);
gtk_label_set_text(GTK_LABEL(count), tmp);
g_free(tmp);
stats_set_fraction(bar,
u->n_ex > 0 ? (double)d / (double)u->n_ex : 0.0);
if (complete) {
gtk_widget_add_css_class(row, "done");
gtk_widget_add_css_class(bar, "done");
if (badge) {
gtk_label_set_text(GTK_LABEL(badge), tr("stats_complete"));
gtk_widget_set_visible(badge, TRUE);
}
} else if (badge) {
gtk_widget_set_visible(badge, FALSE);
}
}
}
void on_stats_back_clicked(GtkButton *button, gpointer user_data) {
const char *target = stats_return_page;
(void)button;
(void)user_data;
if (!target || !target[0])
target = "subjects";
gtk_stack_set_visible_child_name(main_stack, target);
}
void on_stats_clicked(GtkButton *button, gpointer user_data) {
const char *cur;
(void)button;
(void)user_data;
cur = gtk_stack_get_visible_child_name(main_stack);
if (cur && g_strcmp0(cur, "stats") != 0) {
g_free(stats_return_page);
stats_return_page = g_strdup(cur);
}
refresh_stats_ui();
gtk_stack_set_visible_child_name(main_stack, "stats");
}
GtkWidget *stats_metric_card(const char *label_key, GtkWidget **value_out) {
GtkWidget *card;
GtkWidget *value;
GtkWidget *label;
card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(card, "stats-metric");
gtk_widget_set_hexpand(card, TRUE);
value = gtk_label_new("0");
gtk_widget_set_halign(value, GTK_ALIGN_START);
gtk_widget_add_css_class(value, "stats-metric-value");
gtk_box_append(GTK_BOX(card), value);
label = gtk_label_new(NULL);
i18n_bind(label, label_key, 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "stats-metric-label");
gtk_box_append(GTK_BOX(card), label);
*value_out = value;
return card;
}
/* Build one unit row (index, name, badge, count, bar) for the stats list.
* `num` is the row number shown (1-based within its subject). */
GtkWidget *make_unit_stats_row(UnitCtx *u, int num, int global) {
GtkWidget *row;
GtkWidget *head;
GtkWidget *left;
GtkWidget *idx;
GtkWidget *name;
GtkWidget *right;
GtkWidget *count;
GtkWidget *badge;
GtkWidget *bar;
char text[8];
row = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_add_css_class(row, "stats-unit-row");
stats_ui.unit_row[global] = row;
head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_box_append(GTK_BOX(row), head);
left = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_hexpand(left, TRUE);
gtk_box_append(GTK_BOX(head), left);
g_snprintf(text, sizeof(text), "%d", num);
idx = gtk_label_new(text);
gtk_widget_add_css_class(idx, "stats-unit-index");
gtk_widget_set_valign(idx, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(left), idx);
name = gtk_label_new(u->title);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_widget_set_hexpand(name, TRUE);
gtk_label_set_xalign(GTK_LABEL(name), 0.0);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
gtk_widget_add_css_class(name, "stats-unit-name");
gtk_box_append(GTK_BOX(left), name);
right = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
gtk_widget_set_valign(right, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(head), right);
badge = gtk_label_new(NULL);
gtk_widget_add_css_class(badge, "stats-unit-badge");
gtk_widget_set_visible(badge, FALSE);
gtk_box_append(GTK_BOX(right), badge);
stats_ui.unit_badge[global] = badge;
count = gtk_label_new("");
gtk_widget_add_css_class(count, "stats-unit-count");
gtk_box_append(GTK_BOX(right), count);
stats_ui.unit_count[global] = count;
bar = gtk_progress_bar_new();
gtk_widget_add_css_class(bar, "stats-unit-bar");
gtk_widget_set_hexpand(bar, TRUE);
gtk_box_append(GTK_BOX(row), bar);
stats_ui.unit_bar[global] = bar;
return row;
}
GtkWidget *build_stats_page(void) {
GtkWidget *outer;
GtkWidget *scroll;
GtkWidget *page;
GtkWidget *top;
GtkWidget *back;
GtkWidget *center;
GtkWidget *heading;
GtkWidget *sub;
GtkWidget *summary;
GtkWidget *section;
GtkWidget *list;
outer = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(outer, "stats-page");
top = gtk_center_box_new();
gtk_widget_set_margin_top(top, 2);
gtk_widget_set_margin_bottom(top, 6);
gtk_box_append(GTK_BOX(outer), top);
back = make_back_button("subjects");
g_signal_handlers_disconnect_matched(back, G_SIGNAL_MATCH_FUNC,
0, 0, NULL, on_nav_clicked, NULL);
g_signal_connect(back, "clicked", G_CALLBACK(on_stats_back_clicked), NULL);
gtk_center_box_set_start_widget(GTK_CENTER_BOX(top), back);
center = gtk_box_new(GTK_ORIENTATION_VERTICAL, 4);
gtk_widget_set_valign(center, GTK_ALIGN_CENTER);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(top), center);
heading = gtk_label_new(NULL);
i18n_bind(heading, "stats_title", 0);
gtk_widget_set_halign(heading, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(heading, "roadmap-title");
gtk_box_append(GTK_BOX(center), heading);
sub = gtk_label_new(NULL);
i18n_bind(sub, "stats_sub", 0);
gtk_widget_set_halign(sub, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(sub, "roadmap-sub");
gtk_label_set_wrap(GTK_LABEL(sub), TRUE);
gtk_box_append(GTK_BOX(center), sub);
scroll = gtk_scrolled_window_new();
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_set_hexpand(scroll, TRUE);
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_box_append(GTK_BOX(outer), scroll);
page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_halign(page, GTK_ALIGN_FILL);
gtk_widget_set_margin_start(page, 4);
gtk_widget_set_margin_end(page, 4);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), page);
summary = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_widget_add_css_class(summary, "stats-summary");
gtk_widget_set_halign(summary, GTK_ALIGN_FILL);
/* Equal columns; hexpand alone only shares the leftover space, which
* leaves the cards at different widths. */
gtk_box_set_homogeneous(GTK_BOX(summary), TRUE);
gtk_box_append(GTK_BOX(page), summary);
gtk_box_append(GTK_BOX(summary),
stats_metric_card("stats_ex_label", &stats_ui.ex_value));
gtk_box_append(GTK_BOX(summary),
stats_metric_card("stats_pct_label", &stats_ui.pct_value));
gtk_box_append(GTK_BOX(summary),
stats_metric_card("stats_units_label",
&stats_ui.units_value));
stats_ui.overall_bar = gtk_progress_bar_new();
gtk_widget_add_css_class(stats_ui.overall_bar, "stats-overall-bar");
gtk_widget_set_hexpand(stats_ui.overall_bar, TRUE);
gtk_box_append(GTK_BOX(page), stats_ui.overall_bar);
section = gtk_label_new(NULL);
i18n_bind(section, "stats_section", 0);
gtk_widget_set_halign(section, GTK_ALIGN_START);
gtk_widget_add_css_class(section, "stats-section");
gtk_box_append(GTK_BOX(page), section);
list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_box_append(GTK_BOX(page), list);
for (int s = 0; s < NUM_SUBJECTS; s++) {
GtkWidget *subject;
GtkWidget *head;
GtkWidget *name;
GtkWidget *count;
GtkWidget *bar;
gboolean has_units = (s == NET_SUBJ) || (s == HW_SUBJ)
|| (s == CZ_SUBJ) || sub_unit_count[s] > 0;
gboolean has_deutsch_units = sub_unit_count[s] > 0;
subject = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(subject, "stats-subject");
gtk_box_append(GTK_BOX(list), subject);
head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
gtk_box_append(GTK_BOX(subject), head);
name = gtk_label_new(NULL);
i18n_bind(name, sub_keys[s], 0);
gtk_widget_set_halign(name, GTK_ALIGN_START);
gtk_widget_set_hexpand(name, TRUE);
gtk_label_set_xalign(GTK_LABEL(name), 0.0);
gtk_label_set_ellipsize(GTK_LABEL(name), PANGO_ELLIPSIZE_END);
gtk_widget_add_css_class(name, "stats-subject-name");
if (!has_units)
gtk_widget_add_css_class(name, "unit-name-locked");
gtk_box_append(GTK_BOX(head), name);
stats_ui.subj[s].name = name;
count = gtk_label_new(NULL);
gtk_widget_add_css_class(count, "stats-subject-count");
if (!has_units)
gtk_widget_add_css_class(count, "locked");
gtk_box_append(GTK_BOX(head), count);
stats_ui.subj[s].count = count;
bar = gtk_progress_bar_new();
gtk_widget_add_css_class(bar, "stats-subject-bar");
gtk_widget_set_hexpand(bar, TRUE);
gtk_box_append(GTK_BOX(subject), bar);
stats_ui.subj[s].bar = bar;
if (has_deutsch_units) {
GtkWidget *units_box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_add_css_class(units_box, "stats-units");
gtk_box_append(GTK_BOX(subject), units_box);
stats_ui.subj[s].units_box = units_box;
for (int j = 0; j < sub_unit_count[s]; j++) {
int gi = sub_unit_start[s] + j;
gtk_box_append(GTK_BOX(units_box),
make_unit_stats_row(&units[gi], j + 1, gi));
}
}
}
return outer;
}

424
src/subjects.c Normal file
View file

@ -0,0 +1,424 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Subjects page ("Předměty") - serpentine subject map */
/* ------------------------------------------------------------------ */
/* Index 0 (Deutsch), NET_SUBJ (networks), HW_SUBJ (hardware) and CZ_SUBJ
* (Czech) are unlocked. */
const char *sub_keys[NUM_SUBJECTS] = {
"Deutsch",
"Správa počítačových sítí",
"Technické vybavení",
"Český jazyk a literatura",
"Občanská nauka",
"English",
"Matematika",
"Fyzika",
"Základy Přírodopisných věd",
"Technická grafika",
"Prezentační grafika",
"Programování",
"Programové vybavení",
};
/* Slice of the global `units[]` table that each subject owns. A start of -1
* (or a count of 0) means the subject has no content yet; such subjects show
* as locked on the statistics page until their units are added. */
const int sub_unit_start[NUM_SUBJECTS] = {
0, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
};
const int sub_unit_count[NUM_SUBJECTS] = {
NUM_UNITS, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
};
GtkWidget *sub_scroll;
GtkWidget *sub_fixed;
GtkWidget *sub_rail;
GtkWidget *sub_cells[NUM_SUBJECTS];
GtkWidget *sub_labels[NUM_SUBJECTS];
double sub_cx[NUM_SUBJECTS];
double sub_cy[NUM_SUBJECTS];
int sub_rows = 1;
int sub_cols = NUM_SUBJECTS;
int sub_cw = (int)(2.0 * SUB_MX + (NUM_SUBJECTS - 1) * SUB_SPAC);
int sub_ch = (int)(2.0 * SUB_MY);
guint sub_idle;
void sub_layout_geometry(double avail) {
const double phase = 2.0 * G_PI * 1.7 / (double)(NUM_SUBJECTS - 1);
int rows;
int r, c, i;
for (rows = 1; rows <= NUM_SUBJECTS; rows++) {
int cols = (NUM_SUBJECTS + rows - 1) / rows;
if (2.0 * SUB_MX + (cols - 1) * SUB_SPAC <= avail + 1.0)
break;
}
if (rows > NUM_SUBJECTS)
rows = NUM_SUBJECTS;
sub_rows = rows;
sub_cols = (NUM_SUBJECTS + rows - 1) / rows;
if (sub_cols < 1)
sub_cols = 1;
sub_cw = (int)(2.0 * SUB_MX + (sub_cols - 1) * SUB_SPAC);
sub_ch = (int)(2.0 * SUB_MY + (rows - 1) * SUB_GAP);
for (r = 0; r < rows; r++) {
int base = r * sub_cols;
int len = MIN(sub_cols, NUM_SUBJECTS - base);
int fwd = (r % 2) == 0;
for (c = 0; c < len; c++) {
i = base + c;
int cc = fwd ? c : (sub_cols - 1 - c);
sub_cx[i] = SUB_MX + cc * SUB_SPAC;
sub_cy[i] = SUB_MY + r * SUB_GAP
+ SUB_WAVE * sin((double)i * phase);
}
}
}
void sub_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_SUBJECTS - 1) { k = NUM_SUBJECTS - 2; u = 1.0; }
p1x = sub_cx[k]; p1y = sub_cy[k];
p2x = sub_cx[k + 1]; p2y = sub_cy[k + 1];
if (k - 1 >= 0) { p0x = sub_cx[k - 1]; p0y = sub_cy[k - 1]; }
else { p0x = p1x - (p2x - p1x); p0y = p1y - (p2y - p1y); }
if (k + 2 < NUM_SUBJECTS) { p3x = sub_cx[k + 2]; p3y = sub_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 sub_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;
sub_point(t0, &x, &y);
cairo_move_to(cr, x, y);
for (s = 1; s <= n; s++) {
sub_point(t0 + (t1 - t0) * (double)s / (double)n, &x, &y);
cairo_line_to(cr, x, y);
}
}
void draw_sub_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer user_data) {
const double t_end = (double)(NUM_SUBJECTS - 1);
const Rgb rail = color_from_hex(app_theme.rail);
(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);
sub_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);
}
void sub_apply_layout(void) {
int i;
if (!sub_fixed)
return;
for (i = 0; i < NUM_SUBJECTS; i++) {
if (sub_cells[i])
gtk_fixed_move(GTK_FIXED(sub_fixed), sub_cells[i],
(int)(sub_cx[i] - SUB_BUBBLE / 2.0),
(int)(sub_cy[i] - SUB_BUBBLE / 2.0));
if (sub_labels[i])
gtk_fixed_move(GTK_FIXED(sub_fixed), sub_labels[i],
(int)(sub_cx[i] - (SUB_SPAC - 24.0) / 2.0),
(int)(sub_cy[i] + SUB_BUBBLE / 2.0 + 10.0));
}
gtk_widget_set_size_request(sub_fixed, sub_cw, sub_ch);
gtk_widget_set_size_request(sub_rail, sub_cw, sub_ch);
gtk_fixed_move(GTK_FIXED(sub_fixed), sub_rail, 0, 0);
gtk_widget_queue_draw(sub_rail);
}
void sub_relayout(void) {
GtkAdjustment *hadj;
double avail;
if (!sub_scroll)
return;
hadj = gtk_scrolled_window_get_hadjustment(
GTK_SCROLLED_WINDOW(sub_scroll));
avail = gtk_adjustment_get_page_size(hadj);
if (avail < 1.0)
return;
sub_layout_geometry(avail);
sub_apply_layout();
}
gboolean sub_relayout_idle(gpointer data) {
(void)data;
sub_idle = 0;
sub_relayout();
return G_SOURCE_REMOVE;
}
void sub_relayout_later(void) {
if (sub_idle == 0)
sub_idle = g_idle_add(sub_relayout_idle, NULL);
}
void sub_adjust_notify(GtkAdjustment *adj, GParamSpec *ps,
gpointer data) {
(void)adj;
(void)ps;
(void)data;
sub_relayout_later();
}
/* German flag (black / red / gold) drawn in a clipped circle. */
void draw_german_flag(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
double cx = width / 2.0;
double cy = height / 2.0;
double r = MIN(width, height) / 2.0 - 2.0;
double band = (double)height / 3.0;
(void)area;
(void)data;
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_new_path(cr);
cairo_rectangle(cr, 0.0, 0.0, (double)width, band);
cairo_set_source_rgb(cr, 0.18, 0.18, 0.19);
cairo_fill(cr);
cairo_new_path(cr);
cairo_rectangle(cr, 0.0, band, (double)width, band);
cairo_set_source_rgb(cr, 0.72, 0.27, 0.24);
cairo_fill(cr);
cairo_new_path(cr);
cairo_rectangle(cr, 0.0, 2.0 * band, (double)width, band);
cairo_set_source_rgb(cr, 0.84, 0.70, 0.32);
cairo_fill(cr);
cairo_restore(cr);
}
/* Czech flag (white / red with a blue triangle) drawn in a clipped circle. */
void draw_czech_flag(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
double cx = width / 2.0;
double cy = height / 2.0;
double r = MIN(width, height) / 2.0 - 2.0;
(void)area;
(void)data;
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_new_path(cr);
cairo_rectangle(cr, 0.0, 0.0, (double)width, (double)height);
cairo_set_source_rgb(cr, 0.98, 0.98, 0.98);
cairo_fill(cr);
cairo_new_path(cr);
cairo_rectangle(cr, 0.0, (double)height / 2.0,
(double)width, (double)height / 2.0);
cairo_set_source_rgb(cr, 0.83, 0.16, 0.18);
cairo_fill(cr);
cairo_new_path(cr);
cairo_move_to(cr, 0.0, 0.0);
cairo_line_to(cr, (double)width * 0.55, (double)height / 2.0);
cairo_line_to(cr, 0.0, (double)height);
cairo_close_path(cr);
cairo_set_source_rgb(cr, 0.07, 0.24, 0.55);
cairo_fill(cr);
cairo_restore(cr);
}
GtkWidget *build_subjects_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
int i;
sub_layout_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("welcome", "subjects_title", NULL));
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);
sub_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, sub_cw, sub_ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
sub_fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, sub_cw, sub_ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), draw_sub_rail,
NULL, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
sub_rail = rail;
for (i = 0; i < NUM_SUBJECTS; i++) {
GtkWidget *card;
GtkWidget *lbl;
card = gtk_button_new();
gtk_widget_add_css_class(card, "unit-node");
gtk_widget_set_can_focus(card, FALSE);
gtk_widget_set_size_request(card, (int)SUB_BUBBLE, (int)SUB_BUBBLE);
if (i == 0) {
GtkWidget *flag = gtk_drawing_area_new();
gtk_widget_add_css_class(card, "current");
gtk_widget_set_size_request(flag, (int)SUB_BUBBLE,
(int)SUB_BUBBLE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(flag),
draw_german_flag, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(card), flag);
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup("roadmap"), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
} else if (i == NET_SUBJ) {
GtkWidget *wifi = gtk_drawing_area_new();
gtk_widget_add_css_class(card, "current");
gtk_widget_set_size_request(wifi, (int)SUB_BUBBLE,
(int)SUB_BUBBLE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(wifi),
draw_wifi_icon, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(card), wifi);
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup("netyears"), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
} else if (i == HW_SUBJ) {
GtkWidget *chip = gtk_drawing_area_new();
gtk_widget_add_css_class(card, "current");
gtk_widget_set_size_request(chip, (int)SUB_BUBBLE,
(int)SUB_BUBBLE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(chip),
draw_chip_icon, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(card), chip);
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup("hwmap"), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
} else if (i == CZ_SUBJ) {
GtkWidget *flag = gtk_drawing_area_new();
gtk_widget_add_css_class(card, "current");
gtk_widget_set_size_request(flag, (int)SUB_BUBBLE,
(int)SUB_BUBBLE);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(flag),
draw_czech_flag, NULL, NULL);
gtk_button_set_child(GTK_BUTTON(card), flag);
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup("czechmap"), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
} else {
GtkWidget *lock;
gtk_widget_add_css_class(card, "locked");
gtk_widget_set_sensitive(card, FALSE);
lock = icon_area_new(draw_lock_icon,
0.3451, 0.3569, 0.4392, 26);
gtk_button_set_child(GTK_BUTTON(card), lock);
}
sub_cells[i] = card;
gtk_fixed_put(GTK_FIXED(fixed), card, 0, 0);
lbl = gtk_label_new(NULL);
i18n_bind(lbl, sub_keys[i], 0);
gtk_widget_set_size_request(lbl, (int)(SUB_SPAC - 24.0), -1);
gtk_widget_set_halign(lbl, GTK_ALIGN_CENTER);
gtk_label_set_justify(GTK_LABEL(lbl), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_add_css_class(lbl, "unit-name");
if (i != 0 && i != NET_SUBJ && i != HW_SUBJ && i != CZ_SUBJ)
gtk_widget_add_css_class(lbl, "unit-name-locked");
sub_labels[i] = lbl;
gtk_fixed_put(GTK_FIXED(fixed), lbl, 0, 0);
}
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(sub_adjust_notify), NULL);
g_signal_connect(va, "notify::page-size",
G_CALLBACK(sub_adjust_notify), NULL);
sub_apply_layout();
sub_relayout_later();
return page;
}

424
src/theme.c Normal file
View file

@ -0,0 +1,424 @@
#include "maturita.h"
Rgb color_from_hex(unsigned int hex) {
Rgb c;
c.r = (double)((hex >> 16) & 0xFF) / 255.0;
c.g = (double)((hex >> 8) & 0xFF) / 255.0;
c.b = (double)(hex & 0xFF) / 255.0;
return c;
}
/* Linear blend of two colours: t = 0 keeps a, t = 1 returns b. */
Rgb mix_rgb(Rgb a, Rgb b, double t) {
Rgb c;
c.r = a.r + (b.r - a.r) * t;
c.g = a.g + (b.g - a.g) * t;
c.b = a.b + (b.b - a.b) * t;
return c;
}
ThemePalette theme_palette(ThemeId id, ColorMode mode) {
/* Catppuccin Mocha / Latte */
static const ThemePalette cat_dark = {
0x11111b, 0x1e1e2e, 0x181825, 0x313244, 0x45475a, 0x585b70,
0x6c7086, 0xcdd6f4, 0xa6adc8, 0xcba6f7, 0xb4befe, 0x89b4fa,
0xa6e3a1, 0x94e2d5, 0xf9e2af, 0xf38ba8, 0x1e1e2e,
0x2b2d40, 0x24263a, 0x383b52, 0x27293c, 0x1f202e, 0x3b3e58, 0x52556b
};
static const ThemePalette cat_light = {
0xdce0e8, 0xeff1f5, 0xe6e9ef, 0xccd0da, 0xbcc0cc, 0xacb0be,
0x9ca0b0, 0x4c4f69, 0x6c6f85, 0x8839ef, 0x7287fd, 0x1e66f5,
0x40a02b, 0x179299, 0xdf8e1d, 0xd20f39, 0xeff1f5,
0xe6e9ef, 0xdce0e8, 0xbcc0cc, 0xccd0da, 0xdce0e8, 0xbcc0cc, 0x9ca0b0
};
/* Nord */
static const ThemePalette nord_dark = {
0x2e3440, 0x3b4252, 0x2e3440, 0x434c5e, 0x4c566a, 0x616e88,
0x7b88a1, 0xeceff4, 0xd8dee9, 0x88c0d0, 0x81a1c1, 0x5e81ac,
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0x2e3440,
0x434c5e, 0x3b4252, 0x4c566a, 0x2e3440, 0x2e3440, 0x434c5e, 0x4c566a
};
static const ThemePalette nord_light = {
0xd8dee9, 0xeceff4, 0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xb0b8c8,
0x7b88a1, 0x2e3440, 0x4c566a, 0x5e81ac, 0x81a1c1, 0x5e81ac,
0xa3be8c, 0x8fbcbb, 0xebcb8b, 0xbf616a, 0xeceff4,
0xe5e9f0, 0xd8dee9, 0xc7ceda, 0xd8dee9, 0xd8dee9, 0xc7ceda, 0x7b88a1
};
/* Dracula / light soft */
static const ThemePalette dra_dark = {
0x191a21, 0x282a36, 0x21222c, 0x44475a, 0x6272a4, 0x707eb0,
0x6272a4, 0xf8f8f2, 0xbfbfb2, 0xbd93f9, 0xff79c6, 0x8be9fd,
0x50fa7b, 0x8be9fd, 0xf1fa8c, 0xff5555, 0x282a36,
0x383a4a, 0x2f3240, 0x44475a, 0x21222c, 0x191a21, 0x383a4a, 0x6272a4
};
static const ThemePalette dra_light = {
0xd5d7e0, 0xf8f8f2, 0xefefea, 0xe2e2d8, 0xd0d0c4, 0xbfbfb2,
0x9a9a8c, 0x282a36, 0x44475a, 0x7c3aed, 0xd946a6, 0x0891b2,
0x16a34a, 0x0e7490, 0xca8a04, 0xdc2626, 0xf8f8f2,
0xefefea, 0xe2e2d8, 0xd0d0c4, 0xe2e2d8, 0xd5d7e0, 0xd0d0c4, 0x9a9a8c
};
/* Rose Pine / Dawn */
static const ThemePalette rose_dark = {
0x191724, 0x1f1d2e, 0x1f1d2e, 0x26233a, 0x403d52, 0x524f67,
0x6e6a86, 0xe0def4, 0x908caa, 0xc4a7e7, 0xebbcba, 0x9ccfd8,
0x31748f, 0x9ccfd8, 0xf6c177, 0xeb6f92, 0x191724,
0x26233a, 0x21202e, 0x403d52, 0x191724, 0x191724, 0x26233a, 0x524f67
};
static const ThemePalette rose_light = {
0xf2e9e1, 0xfaf4ed, 0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xcecacd,
0x9893a5, 0x575279, 0x797593, 0x907aa9, 0xd7827e, 0x56949f,
0x286983, 0x56949f, 0xea9d34, 0xb4637a, 0xfaf4ed,
0xfffaf3, 0xf2e9e1, 0xdfdad9, 0xf2e9e1, 0xf2e9e1, 0xdfdad9, 0x9893a5
};
/* Ocean / Tokyo Night inspired */
static const ThemePalette ocean_dark = {
0x16161e, 0x1a1b26, 0x16161e, 0x24283b, 0x414868, 0x565f89,
0x565f89, 0xc0caf5, 0xa9b1d6, 0x7aa2f7, 0xbb9af7, 0x7dcfff,
0x9ece6a, 0x73daca, 0xe0af68, 0xf7768e, 0x1a1b26,
0x24283b, 0x1f2335, 0x3b4261, 0x16161e, 0x16161e, 0x24283b, 0x414868
};
static const ThemePalette ocean_light = {
0xd6dae8, 0xe1e2ec, 0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0x9aa0b5,
0x7a8199, 0x2e3446, 0x4a5168, 0x2e7de9, 0x7847bd, 0x007197,
0x387068, 0x007197, 0x8c6c3e, 0xc64343, 0xe1e2ec,
0xd5d6e2, 0xc8c9d6, 0xb4b6c5, 0xc8c9d6, 0xd6dae8, 0xb4b6c5, 0x7a8199
};
/* Gruvbox */
static const ThemePalette gruv_dark = {
0x1d2021, 0x282828, 0x1d2021, 0x3c3836, 0x504945, 0x665c54,
0x928374, 0xebdbb2, 0xa89984, 0xfe8019, 0xd3869b, 0x83a598,
0xb8bb26, 0x8ec07c, 0xfabd2f, 0xfb4934, 0x1d2021,
0x3c3836, 0x32302f, 0x504945, 0x1d2021, 0x1d2021, 0x3c3836, 0x504945
};
static const ThemePalette gruv_light = {
0xebdbb2, 0xfbf1c7, 0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xbdae93,
0x928374, 0x3c3836, 0x504945, 0xd65d0e, 0xb16286, 0x076678,
0x79740e, 0x427b58, 0xb57614, 0x9d0006, 0xfbf1c7,
0xf2e5bc, 0xebdbb2, 0xd5c4a1, 0xebdbb2, 0xebdbb2, 0xd5c4a1, 0x928374
};
/* Solarized */
static const ThemePalette sol_dark = {
0x002b36, 0x073642, 0x002b36, 0x586e75, 0x657b83, 0x839496,
0x93a1a1, 0xfdf6e3, 0x93a1a1, 0x268bd2, 0x6c71c4, 0x2aa198,
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0x002b36,
0x073642, 0x002b36, 0x586e75, 0x002b36, 0x002b36, 0x073642, 0x586e75
};
static const ThemePalette sol_light = {
0xeee8d5, 0xfdf6e3, 0xeee8d5, 0xeee8d5, 0xe2dbc6, 0xc9c0a6,
0x657b83, 0x586e75, 0x657b83, 0x268bd2, 0x6c71c4, 0x2aa198,
0x859900, 0x2aa198, 0xb58900, 0xdc322f, 0xfdf6e3,
0xeee8d5, 0xe6dfc8, 0x93a1a1, 0xeee8d5, 0xeee8d5, 0x93a1a1, 0x586e75
};
/* Everforest */
static const ThemePalette ever_dark = {
0x1e2326, 0x272e33, 0x2d353b, 0x374145, 0x4f5b58, 0x7a8478,
0x859289, 0xd3c6aa, 0xa7c080, 0xa7c080, 0xd699b6, 0x7fbbb3,
0xa7c080, 0x83c092, 0xdbbc7f, 0xe67e80, 0x272e33,
0x374145, 0x2d353b, 0x4f5b58, 0x1e2326, 0x1e2326, 0x374145, 0x4f5b58
};
static const ThemePalette ever_light = {
0xe6e2cc, 0xfdf6e3, 0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xa6b0a0,
0x939f91, 0x5c6a72, 0x829181, 0x8da101, 0xdf69ba, 0x35a77c,
0x8da101, 0x35a77c, 0xdfa000, 0xf85552, 0xfdf6e3,
0xf4f0d9, 0xefebd4, 0xe0dcc7, 0xe6e2cc, 0xe6e2cc, 0xe0dcc7, 0x939f91
};
/* Monokai */
static const ThemePalette mono_dark = {
0x1d1e19, 0x272822, 0x1e1f1a, 0x3e3d32, 0x49483e, 0x75715e,
0x75715e, 0xf8f8f2, 0xa59f85, 0xf92672, 0xae81ff, 0x66d9ef,
0xa6e22e, 0xa1efe4, 0xe6db74, 0xf92672, 0x272822,
0x3e3d32, 0x2e2f29, 0x49483e, 0x1d1e19, 0x1d1e19, 0x3e3d32, 0x49483e
};
static const ThemePalette mono_light = {
0xe6e3d4, 0xfaf8f0, 0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xb8b49e,
0x8a8674, 0x2d2a2e, 0x5b5854, 0xe14775, 0x7c5cbf, 0x1c7d9b,
0x5b8c2a, 0x1c7d9b, 0xb08900, 0xe14775, 0xfaf8f0,
0xf3f0e4, 0xe8e4d4, 0xd6d2c0, 0xe6e3d4, 0xe6e3d4, 0xd6d2c0, 0x8a8674
};
/* One Dark / One Light */
static const ThemePalette one_dark = {
0x21252b, 0x282c34, 0x21252b, 0x3b4048, 0x4b5263, 0x5c6370,
0x5c6370, 0xabb2bf, 0x7f848e, 0xc678dd, 0x61afef, 0x56b6c2,
0x98c379, 0x56b6c2, 0xe5c07b, 0xe06c75, 0x282c34,
0x3b4048, 0x2c313a, 0x4b5263, 0x21252b, 0x21252b, 0x3b4048, 0x4b5263
};
static const ThemePalette one_light = {
0xe5e5e6, 0xfafafa, 0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7,
0xa0a1a7, 0x383a42, 0x696c77, 0xa626a4, 0x4078f2, 0x0184bc,
0x50a14f, 0x0184bc, 0xc18401, 0xe45649, 0xfafafa,
0xf0f0f0, 0xe5e5e6, 0xd0d0d2, 0xe5e5e6, 0xe5e5e6, 0xd0d0d2, 0xa0a1a7
};
switch (id) {
case THEME_NORD:
return mode == MODE_LIGHT ? nord_light : nord_dark;
case THEME_DRACULA:
return mode == MODE_LIGHT ? dra_light : dra_dark;
case THEME_ROSE_PINE:
return mode == MODE_LIGHT ? rose_light : rose_dark;
case THEME_OCEAN:
return mode == MODE_LIGHT ? ocean_light : ocean_dark;
case THEME_GRUVBOX:
return mode == MODE_LIGHT ? gruv_light : gruv_dark;
case THEME_SOLARIZED:
return mode == MODE_LIGHT ? sol_light : sol_dark;
case THEME_EVERFOREST:
return mode == MODE_LIGHT ? ever_light : ever_dark;
case THEME_MONOKAI:
return mode == MODE_LIGHT ? mono_light : mono_dark;
case THEME_ONE_DARK:
return mode == MODE_LIGHT ? one_light : one_dark;
case THEME_CATPPUCCIN:
default:
return mode == MODE_LIGHT ? cat_light : cat_dark;
}
}
char *read_css_file(void) {
gchar *contents = NULL;
GError *err = NULL;
if (!g_file_get_contents(CSS_FILE, &contents, NULL, &err)) {
g_warning("Could not load stylesheet '%s': %s", CSS_FILE,
err ? err->message : "unknown error");
if (err)
g_error_free(err);
return g_strdup("");
}
return contents;
}
typedef struct {
double scale;
} FontScale;
static gboolean scale_font_size_eval(const GMatchInfo *info, GString *result,
gpointer user_data) {
const FontScale *fs = user_data;
char *space = g_match_info_fetch(info, 1);
char *num = g_match_info_fetch(info, 2);
int px = (int)g_ascii_strtoll(num, NULL, 10);
int scaled = (int)(px * fs->scale + 0.5);
g_string_append_printf(result, "font-size:%s%dpx", space, scaled);
g_free(space);
g_free(num);
return FALSE;
}
/* Scale every explicit `font-size: Npx` declaration so the interface grows on
* large (e.g. maximised / high-DPI) windows. Layout metrics stay untouched.
* Rules after the no-scale marker keep their fixed size, so symbol / icon
* glyphs do not scale. */
static char *scale_font_sizes(const char *css, double scale) {
static const char marker[] = "/* no-scale */";
const char *pos;
char *head;
char *scaled_head;
char *out;
GRegex *re;
FontScale fs;
if (scale <= 1.0001)
return g_strdup(css);
pos = g_strstr_len(css, -1, marker);
head = pos ? g_strndup(css, (gsize)(pos - css)) : g_strdup(css);
fs.scale = scale;
re = g_regex_new("font-size:([ \t]*)([0-9]+)px", 0, 0, NULL);
scaled_head = g_regex_replace_eval(re, head, -1, 0, 0,
scale_font_size_eval, &fs, NULL);
g_regex_unref(re);
g_free(head);
if (pos)
out = g_strconcat(scaled_head, pos, NULL);
else
out = scaled_head;
g_free(scaled_head);
return out;
}
/* The palette is data-driven, so its @define-color bindings are generated
* here and prepended to the rules loaded from the external stylesheet. */
char *build_theme_css(const ThemePalette *p) {
char *palette = g_strdup_printf(
"@define-color bg_crust #%06x;\n"
"@define-color bg_base #%06x;\n"
"@define-color bg_mantle #%06x;\n"
"@define-color bg_surface0 #%06x;\n"
"@define-color bg_surface1 #%06x;\n"
"@define-color bg_surface2 #%06x;\n"
"@define-color fg_overlay #%06x;\n"
"@define-color fg_text #%06x;\n"
"@define-color fg_subtext #%06x;\n"
"@define-color accent #%06x;\n"
"@define-color accent2 #%06x;\n"
"@define-color accent3 #%06x;\n"
"@define-color success #%06x;\n"
"@define-color success2 #%06x;\n"
"@define-color warning #%06x;\n"
"@define-color error #%06x;\n"
"@define-color on_accent #%06x;\n"
"@define-color node_bg #%06x;\n"
"@define-color locked_bg #%06x;\n"
"@define-color locked_border #%06x;\n",
p->crust, p->base, p->mantle, p->surface0, p->surface1, p->surface2,
p->overlay, p->text, p->subtext, p->accent, p->accent2, p->accent3,
p->success, p->success2, p->warning, p->error, p->on_accent,
p->node, p->locked_bg, p->locked_border);
char *rules = read_css_file();
char *scaled = scale_font_sizes(rules, app_ui_scale);
char *css = g_strconcat(palette, scaled, NULL);
g_free(palette);
g_free(rules);
g_free(scaled);
return css;
}
void save_settings(void) {
GKeyFile *kf = g_key_file_new();
gchar *data;
g_key_file_set_string(kf, "ui", "theme", theme_names[app_theme_id]);
g_key_file_set_string(kf, "ui", "mode",
app_color_mode == MODE_LIGHT ? "light" : "dark");
g_key_file_set_string(kf, "ui", "lang",
app_lang == LANG_EN ? "en" : "cs");
if (g_mkdir_with_parents(PROGRESS_DIR, 0755) != 0) {
g_key_file_free(kf);
return;
}
data = g_key_file_to_data(kf, NULL, NULL);
g_key_file_free(kf);
if (data) {
GError *err = NULL;
g_file_set_contents(SETTINGS_FILE, data, -1, &err);
if (err)
g_error_free(err);
g_free(data);
}
}
void load_settings(void) {
GKeyFile *kf = g_key_file_new();
GError *err = NULL;
gchar *theme = NULL;
gchar *mode = NULL;
if (!g_key_file_load_from_file(kf, SETTINGS_FILE, G_KEY_FILE_NONE, &err)) {
if (err)
g_error_free(err);
g_key_file_free(kf);
return;
}
theme = g_key_file_get_string(kf, "ui", "theme", NULL);
mode = g_key_file_get_string(kf, "ui", "mode", NULL);
if (theme) {
for (int i = 0; i < THEME_COUNT; i++) {
if (g_ascii_strcasecmp(theme, theme_names[i]) == 0) {
app_theme_id = (ThemeId)i;
break;
}
}
g_free(theme);
}
if (mode) {
if (g_ascii_strcasecmp(mode, "light") == 0 ||
g_ascii_strcasecmp(mode, "white") == 0)
app_color_mode = MODE_LIGHT;
else
app_color_mode = MODE_DARK;
g_free(mode);
}
{
gchar *lang = g_key_file_get_string(kf, "ui", "lang", NULL);
if (lang) {
if (g_ascii_strcasecmp(lang, "en") == 0 ||
g_ascii_strcasecmp(lang, "english") == 0)
app_lang = LANG_EN;
else
app_lang = LANG_CS;
g_free(lang);
}
}
g_key_file_free(kf);
}
/* GTK only re-resolves the style of widgets that are actually drawn after a
* CSS provider reload. Pages that are not the current stack child keep their
* previously resolved colors, so after switching the theme any page you then
* open still shows the old palette (e.g. near-white text in light mode).
* Adding and immediately removing a dummy class forces GTK to invalidate the
* style node of every widget, including hidden pages. */
void force_restyle_tree(GtkWidget *w) {
GtkWidget *child;
if (!w)
return;
gtk_widget_add_css_class(w, "m4-restyle");
gtk_widget_remove_css_class(w, "m4-restyle");
for (child = gtk_widget_get_first_child(w); child;
child = gtk_widget_get_next_sibling(child))
force_restyle_tree(child);
}
void apply_theme(void) {
char *css;
GtkSettings *settings = gtk_settings_get_default();
app_theme = theme_palette(app_theme_id, app_color_mode);
if (settings) {
g_object_set(settings,
"gtk-application-prefer-dark-theme",
app_color_mode == MODE_DARK,
NULL);
}
css = build_theme_css(&app_theme);
if (!theme_provider) {
theme_provider = gtk_css_provider_new();
gtk_style_context_add_provider_for_display(
gdk_display_get_default(),
GTK_STYLE_PROVIDER(theme_provider),
GTK_STYLE_PROVIDER_PRIORITY_USER);
}
#if GTK_CHECK_VERSION(4, 12, 0)
gtk_css_provider_load_from_string(theme_provider, css);
#else
/* GTK 4.6 (Ubuntu 22.04) — load_from_string arrived in 4.12. */
gtk_css_provider_load_from_data(theme_provider, css, -1);
#endif
g_free(css);
refresh_welcome_heading();
if (main_window) {
gtk_widget_queue_draw(GTK_WIDGET(main_window));
force_restyle_tree(GTK_WIDGET(main_window));
}
if (road_rail)
gtk_widget_queue_draw(road_rail);
for (int i = 0; i < NUM_UNLOCKED; i++)
if (units[i].ex_rail)
gtk_widget_queue_draw(units[i].ex_rail);
for (int i = 0; i < THEME_COUNT; i++) {
if (theme_swatch_areas[i])
gtk_widget_queue_draw(theme_swatch_areas[i]);
}
net_rail_theme_reset();
hw_rail_theme_reset();
czech_rail_theme_reset();
book_rail_theme_reset();
}

410
src/unitmap.c Normal file
View file

@ -0,0 +1,410 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Unit 1 exercise path (roadmap-style serpentine) */
/* ------------------------------------------------------------------ */
/* Compute the serpentine exercise layout for the given available width. */
void ex_layout_geometry(UnitCtx *u, double avail) {
const int n = u->n_ex;
const double phase = 2.0 * G_PI * 1.7 / (double)(n > 1 ? n - 1 : 1);
const double lift = u->has_branch ? EX_BRANCH_LIFT : 0.0;
int rows;
int r, c, i;
if (n < 1)
return;
for (rows = 1; rows <= n; rows++) {
int cols = (n + rows - 1) / rows;
if (2.0 * EX_MX + (cols - 1) * EX_SPAC <= avail + 1.0)
break;
}
if (rows > n)
rows = n;
u->ex_rows = rows;
u->ex_cols = (n + rows - 1) / rows;
if (u->ex_cols < 1)
u->ex_cols = 1;
u->ex_cw = (int)(2.0 * EX_MX + (u->ex_cols - 1) * EX_SPAC);
u->ex_ch = (int)(2.0 * EX_MY + (rows - 1) * EX_GAP + lift);
for (r = 0; r < rows; r++) {
int base = r * u->ex_cols;
int len = MIN(u->ex_cols, n - base);
int fwd = (r % 2) == 0;
for (c = 0; c < len; c++) {
i = base + c;
int cc = fwd ? c : (u->ex_cols - 1 - c);
u->ex_cx[i] = EX_MX + cc * EX_SPAC;
u->ex_cy[i] = EX_MY + lift + r * EX_GAP
+ EX_WAVE * sin((double)i * phase);
}
}
/* The branch hangs above exercise 2 (index 1), nudged sideways so its
* connector is a slanted link rather than a dead-straight vertical line. */
if (u->has_branch && n > 1) {
u->branch_cx = u->ex_cx[1] + EX_BRANCH_DX;
u->branch_cy = u->ex_cy[1] - EX_BRANCH_LIFT;
}
}
void ex_point(UnitCtx *u, double t, double *ox, double *oy) {
const int n = u->n_ex;
int k = (int)t;
double uu = t - (double)k;
double u2, u3;
double p0x, p0y, p1x, p1y, p2x, p2y, p3x, p3y;
if (n < 2) { *ox = u->ex_cx[0]; *oy = u->ex_cy[0]; return; }
if (k < 0) { k = 0; uu = 0.0; }
if (k >= n - 1) { k = n - 2; uu = 1.0; }
p1x = u->ex_cx[k]; p1y = u->ex_cy[k];
p2x = u->ex_cx[k + 1]; p2y = u->ex_cy[k + 1];
if (k - 1 >= 0) { p0x = u->ex_cx[k - 1]; p0y = u->ex_cy[k - 1]; }
else { p0x = p1x - (p2x - p1x); p0y = p1y - (p2y - p1y); }
if (k + 2 < n) { p3x = u->ex_cx[k + 2]; p3y = u->ex_cy[k + 2]; }
else { p3x = p2x + (p2x - p1x); p3y = p2y + (p2y - p1y); }
u2 = uu * uu;
u3 = u2 * uu;
*ox = 0.5 * (2.0 * p1x + (-p0x + p2x) * uu
+ (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) * uu
+ (2.0 * p0y - 5.0 * p1y + 4.0 * p2y - p3y) * u2
+ (-p0y + 3.0 * p1y - 3.0 * p2y + p3y) * u3);
}
void ex_path(UnitCtx *u, 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;
ex_point(u, t0, &x, &y);
cairo_move_to(cr, x, y);
for (s = 1; s <= n; s++) {
ex_point(u, t0 + (t1 - t0) * (double)s / (double)n, &x, &y);
cairo_line_to(cr, x, y);
}
}
/* Wavy link from an exercise bubble up to the off-path branch. */
static void branch_path(cairo_t *cr, double x0, double y0,
double x1, double y1) {
double dy = y0 - y1;
double c1x = x0 + EX_WAVE * 1.3;
double c1y = y0 - dy * 0.45;
double c2x = x1 - EX_WAVE * 1.3;
double c2y = y1 + dy * 0.45;
cairo_move_to(cr, x0, y0);
cairo_curve_to(cr, c1x, c1y, c2x, c2y, x1, y1);
}
void draw_ex_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer user_data) {
UnitCtx *u = user_data;
const double t_end = (double)(u->n_ex - 1);
const Rgb slate = color_from_hex(app_theme.surface1);
const Rgb green = color_from_hex(app_theme.success);
const Rgb rail = color_from_hex(app_theme.rail);
(void)area;
(void)width;
(void)height;
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);
ex_path(u, cr, 0.0, t_end);
cairo_set_line_width(cr, 14);
cairo_set_source_rgb(cr, rail.r, rail.g, rail.b);
cairo_stroke(cr);
cairo_new_path(cr);
ex_path(u, cr, 0.0, t_end);
cairo_set_line_width(cr, 9);
cairo_set_source_rgb(cr, slate.r, slate.g, slate.b);
cairo_stroke(cr);
for (int k = 0; k < u->n_ex - 1; k++) {
if (!(u->done[k + 1] || u->done[k + 2]))
continue;
cairo_new_path(cr);
ex_path(u, cr, (double)k, (double)(k + 1));
cairo_set_source_rgba(cr, green.r, green.g, green.b, 0.85);
cairo_set_line_width(cr, 9);
cairo_stroke(cr);
}
/* Off-path branch connector up from exercise 2. */
if (u->has_branch && u->n_ex > 1) {
gboolean bdone = u->done[u->branch_ex];
cairo_new_path(cr);
branch_path(cr, u->ex_cx[1], u->ex_cy[1], u->branch_cx, u->branch_cy);
cairo_set_line_width(cr, 14);
cairo_set_source_rgb(cr, rail.r, rail.g, rail.b);
cairo_stroke(cr);
cairo_new_path(cr);
branch_path(cr, u->ex_cx[1], u->ex_cy[1], u->branch_cx, u->branch_cy);
cairo_set_line_width(cr, 9);
if (bdone)
cairo_set_source_rgba(cr, green.r, green.g, green.b, 0.85);
else
cairo_set_source_rgb(cr, slate.r, slate.g, slate.b);
cairo_stroke(cr);
}
}
void ex_apply_layout(UnitCtx *u) {
int i;
if (!u->ex_fixed)
return;
for (i = 0; i < u->n_ex; i++) {
if (u->ex_cells[i])
gtk_fixed_move(GTK_FIXED(u->ex_fixed), u->ex_cells[i],
(int)(u->ex_cx[i] - EX_BUBBLE / 2.0),
(int)(u->ex_cy[i] - EX_BUBBLE / 2.0));
if (u->ex_labels[i])
gtk_fixed_move(GTK_FIXED(u->ex_fixed), u->ex_labels[i],
(int)(u->ex_cx[i] - (EX_SPAC - 20.0) / 2.0),
(int)(u->ex_cy[i] + EX_BUBBLE / 2.0 + 8.0));
}
if (u->has_branch && u->branch_cell) {
gtk_fixed_move(GTK_FIXED(u->ex_fixed), u->branch_cell,
(int)(u->branch_cx - EX_BUBBLE / 2.0),
(int)(u->branch_cy - EX_BUBBLE / 2.0));
if (u->branch_label)
gtk_fixed_move(GTK_FIXED(u->ex_fixed), u->branch_label,
(int)(u->branch_cx + EX_BUBBLE / 2.0 + 10.0),
(int)(u->branch_cy - 14.0));
}
gtk_widget_set_size_request(u->ex_fixed, u->ex_cw, u->ex_ch);
gtk_widget_set_size_request(u->ex_rail, u->ex_cw, u->ex_ch);
gtk_fixed_move(GTK_FIXED(u->ex_fixed), u->ex_rail, 0, 0);
gtk_widget_queue_draw(u->ex_rail);
}
void ex_relayout(UnitCtx *u) {
GtkAdjustment *hadj;
double avail;
if (!u->ex_scroll)
return;
hadj = gtk_scrolled_window_get_hadjustment(
GTK_SCROLLED_WINDOW(u->ex_scroll));
avail = gtk_adjustment_get_page_size(hadj);
if (avail < 1.0)
return;
ex_layout_geometry(u, avail);
ex_apply_layout(u);
}
gboolean ex_relayout_idle(gpointer data) {
UnitCtx *u = data;
u->ex_idle = 0;
ex_relayout(u);
return G_SOURCE_REMOVE;
}
void ex_relayout_later(UnitCtx *u) {
if (u->ex_idle == 0)
u->ex_idle = g_idle_add(ex_relayout_idle, u);
}
void ex_adjust_notify(GtkAdjustment *adj, GParamSpec *ps,
gpointer data) {
(void)adj;
(void)ps;
ex_relayout_later(data);
}
GtkWidget *make_bubble(UnitCtx *u, int n) {
GtkWidget *btn;
GtkWidget *vbox;
GtkWidget *num_label;
GtkWidget *icon;
char *num;
char *target;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "ex-bubble");
gtk_widget_set_size_request(btn, (int)EX_BUBBLE, (int)EX_BUBBLE);
gtk_widget_set_can_focus(btn, FALSE);
if (u->done[n])
gtk_widget_add_css_class(btn, "done");
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(btn), vbox);
num = g_strdup_printf("%d", n);
num_label = gtk_label_new(num);
g_free(num);
gtk_widget_set_halign(num_label, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(num_label, "bubble-number");
gtk_box_append(GTK_BOX(vbox), num_label);
icon = icon_area_new(draw_check_icon, 0.1176, 0.1176, 0.1804, 16);
gtk_widget_set_visible(icon, u->done[n]);
gtk_box_append(GTK_BOX(vbox), icon);
u->ex_cells[n - 1] = btn;
u->ex_icons[n] = icon;
target = g_strdup_printf("%se%d", u->ex_tag, n);
g_object_set_data_full(G_OBJECT(btn), "target", target, g_free);
g_signal_connect(btn, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return btn;
}
static GtkWidget *make_branch_bubble(UnitCtx *u) {
GtkWidget *btn;
GtkWidget *vbox;
GtkWidget *star;
GtkWidget *icon;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "ex-bubble");
gtk_widget_add_css_class(btn, "ex-branch");
gtk_widget_set_size_request(btn, (int)EX_BUBBLE, (int)EX_BUBBLE);
gtk_widget_set_can_focus(btn, FALSE);
if (u->done[u->branch_ex])
gtk_widget_add_css_class(btn, "done");
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(btn), vbox);
star = gtk_label_new("★");
gtk_widget_set_halign(star, GTK_ALIGN_CENTER);
gtk_widget_add_css_class(star, "bubble-number");
gtk_widget_add_css_class(star, "branch-star");
gtk_box_append(GTK_BOX(vbox), star);
icon = icon_area_new(draw_check_icon, 0.1176, 0.1176, 0.1804, 16);
gtk_widget_set_visible(icon, u->done[u->branch_ex]);
gtk_box_append(GTK_BOX(vbox), icon);
u->branch_cell = btn;
u->branch_icon = icon;
g_object_set_data_full(G_OBJECT(btn), "target",
g_strdup(u->branch_target), g_free);
g_signal_connect(btn, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return btn;
}
GtkWidget *build_unit_page(UnitCtx *u) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
int i;
if (u->n_ex < 1)
return NULL;
ex_layout_geometry(u, 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(u->back_target ? u->back_target : "roadmap",
u->title, u->sub_key));
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);
u->ex_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, u->ex_cw, u->ex_ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
u->ex_fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, u->ex_cw, u->ex_ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), draw_ex_rail,
u, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
u->ex_rail = rail;
for (i = 0; i < u->n_ex; i++) {
GtkWidget *btn = make_bubble(u, i + 1);
GtkWidget *lbl = gtk_label_new(u->ex_names[i + 1]);
u->ex_labels[i] = lbl;
gtk_widget_set_size_request(lbl, (int)(EX_SPAC - 20.0), -1);
gtk_widget_set_halign(lbl, GTK_ALIGN_CENTER);
gtk_label_set_justify(GTK_LABEL(lbl), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(lbl), TRUE);
gtk_widget_add_css_class(lbl, "ex-label");
gtk_fixed_put(GTK_FIXED(fixed), btn, 0, 0);
gtk_fixed_put(GTK_FIXED(fixed), lbl, 0, 0);
}
if (u->has_branch && u->branch_target) {
GtkWidget *btn = make_branch_bubble(u);
GtkWidget *lbl = gtk_label_new(u->branch_name);
u->branch_label = lbl;
gtk_widget_set_halign(lbl, GTK_ALIGN_START);
gtk_widget_add_css_class(lbl, "ex-label");
gtk_widget_set_visible(lbl, TRUE);
gtk_fixed_put(GTK_FIXED(fixed), btn, 0, 0);
gtk_fixed_put(GTK_FIXED(fixed), lbl, 0, 0);
}
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(ex_adjust_notify), u);
g_signal_connect(va, "notify::page-size",
G_CALLBACK(ex_adjust_notify), u);
ex_apply_layout(u);
ex_relayout_later(u);
return page;
}

902
src/update.c Normal file
View file

@ -0,0 +1,902 @@
#include "maturita.h"
#ifdef _WIN32
#include <process.h>
#define update_getpid() ((long)_getpid())
#else
#include <unistd.h>
#define update_getpid() ((long)getpid())
#endif
/* ------------------------------------------------------------------ */
/* Self-update */
/* ------------------------------------------------------------------ */
/* Networking runs through the `curl` command rather than a linked HTTP
* library. curl already handles TLS, redirects and proxies, it ships with
* macOS, with every desktop Linux and with Windows 10 and later, and using it
* keeps the three bundling scripts free of another shared library to ship.
* Every call is asynchronous, so a slow or unreachable network never blocks
* the interface.
*
* Packages are not published as releases. They live on the `builds` branch of
* the repository, which CI rewrites on every change to main, so an update is
* simply whatever that branch currently holds. The branch also carries a
* VERSION file naming the commit its packages were built from; comparing that
* against the commit baked into this binary is the whole check. Serving it
* from raw.githubusercontent.com avoids the REST API and its unauthenticated
* rate limit, and needs no JSON parser.
*
* An application cannot reliably replace its own files while it is running, so
* all three platforms follow the same shape: unpack the new version into a
* staging directory beside the current install, write a small script that
* waits for this process to exit before swapping it in, then launch that
* script detached and quit. Staging beside the install keeps the final move on
* one filesystem and proves up front that the location is writable. */
#define UPDATE_BASE_URL "https://raw.githubusercontent.com/" UPDATE_REPO \
"/" UPDATE_BRANCH "/"
#define UPDATE_VERSION_URL UPDATE_BASE_URL "VERSION"
#define UPDATE_STAGING_DIR ".maturita-update"
#ifdef _WIN32
#define NULL_DEVICE "NUL"
#else
#define NULL_DEVICE "/dev/null"
#endif
typedef enum {
UPDATE_IDLE,
UPDATE_CHECKING,
UPDATE_UP_TO_DATE,
UPDATE_AVAILABLE,
UPDATE_DOWNLOADING,
UPDATE_STAGED,
UPDATE_FAILED,
} UpdateState;
/* How this copy is installed, which decides what the update has to replace: a
* macOS bundle, a Windows install directory, or a single AppImage file. Builds
* run straight from the source tree cannot update themselves. */
typedef enum {
INSTALL_UNSUPPORTED,
INSTALL_MACOS_BUNDLE,
INSTALL_WINDOWS_DIR,
INSTALL_APPIMAGE,
} InstallKind;
static struct {
UpdateState state;
gboolean interactive; /* user asked, so also report "up to date" */
char *latest; /* commit the published packages were built from */
char *staging; /* scratch directory beside the install */
char *archive; /* asset downloaded into the staging directory */
char *source; /* unpacked version the swap script moves in */
char *script; /* swap script, run after this process exits */
char *error;
goffset total; /* asset size, 0 when the server did not say */
guint poll_id;
GtkWidget *banner;
GtkWidget *banner_label;
GtkWidget *banner_action;
GtkWidget *status; /* status line in the settings popover */
GtkWidget *action; /* check / update / restart button */
GtkWidget *progress;
} up;
/* ------------------------------------------------------------------ */
/* Build identity */
/* ------------------------------------------------------------------ */
/* Commits are not ordered, so there is nothing to compare: the branch only
* ever holds the current build, and anything else means this copy is behind.
* Short and full forms count as the same commit, so the interface can show a
* short one without confusing the check. */
static gboolean same_commit(const char *a, const char *b) {
size_t la, lb, n;
if (!a || !b || !*a || !*b)
return FALSE;
la = strlen(a);
lb = strlen(b);
n = MIN(la, lb);
return n >= 7 && g_ascii_strncasecmp(a, b, n) == 0;
}
/* Only builds published by CI carry a commit; see APP_COMMIT in maturita.h. */
static gboolean is_published_build(void) {
return g_ascii_isxdigit(APP_COMMIT[0]) && strlen(APP_COMMIT) >= 7;
}
/* Commit hashes are unreadable at full length; seven characters is what git
* itself shows. */
static char *short_commit(const char *sha) {
return g_strndup(sha ? sha : "", 7);
}
/* ------------------------------------------------------------------ */
/* Install layout */
/* ------------------------------------------------------------------ */
/* The asset published for this platform, or NULL when the release carries
* none. Only arm64 macOS and x86_64 Linux / Windows are built. */
static const char *asset_name(void) {
#if defined(_WIN32)
return "maturita-windows-x64.zip";
#elif defined(__APPLE__)
#if defined(__aarch64__) || defined(__arm64__)
return "maturita-macos-arm64.zip";
#else
return NULL;
#endif
#elif defined(__x86_64__)
return "maturita-linux-x86_64.AppImage";
#else
return NULL;
#endif
}
/* What the update replaces, plus the directory holding it. */
static InstallKind install_target(char **target_out, char **parent_out) {
char *exe = app_executable_path();
InstallKind kind = INSTALL_UNSUPPORTED;
char *target = NULL;
*target_out = NULL;
*parent_out = NULL;
#ifndef _WIN32
{
const char *appimage = g_getenv("APPIMAGE");
if (appimage && appimage[0]) {
target = g_strdup(appimage);
kind = INSTALL_APPIMAGE;
}
}
#endif
#ifdef __APPLE__
/* .../Maturita.app/Contents/MacOS/maturita -> .../Maturita.app */
if (!target && exe) {
char *macos_dir = g_path_get_dirname(exe);
char *contents = g_path_get_dirname(macos_dir);
char *bundle = g_path_get_dirname(contents);
if (g_str_has_suffix(macos_dir, "MacOS") &&
g_str_has_suffix(bundle, ".app")) {
target = g_strdup(bundle);
kind = INSTALL_MACOS_BUNDLE;
}
g_free(bundle);
g_free(contents);
g_free(macos_dir);
}
#endif
#ifdef _WIN32
/* The Windows package is a plain directory holding the exe and the GTK
* runtime, so the directory itself is what gets refreshed. */
if (!target && exe) {
target = g_path_get_dirname(exe);
kind = INSTALL_WINDOWS_DIR;
}
#endif
if (target) {
*target_out = target;
*parent_out = g_path_get_dirname(target);
}
g_free(exe);
return kind;
}
/* An update is only worth offering when a release carries an asset for this
* platform and the running copy is one this code knows how to replace. */
static gboolean update_supported(void) {
char *target = NULL;
char *parent = NULL;
gboolean ok = install_target(&target, &parent) != INSTALL_UNSUPPORTED;
g_free(target);
g_free(parent);
return ok && asset_name() != NULL;
}
/* Depth-first delete; GLib has no recursive remove. */
static void remove_tree(const char *path) {
GDir *dir;
if (!path || !g_file_test(path, G_FILE_TEST_EXISTS))
return;
if (g_file_test(path, G_FILE_TEST_IS_DIR) &&
!g_file_test(path, G_FILE_TEST_IS_SYMLINK)) {
const char *name;
dir = g_dir_open(path, 0, NULL);
if (dir) {
while ((name = g_dir_read_name(dir))) {
char *child = g_build_filename(path, name, NULL);
remove_tree(child);
g_free(child);
}
g_dir_close(dir);
}
g_rmdir(path);
} else {
g_remove(path);
}
}
/* An interrupted swap leaves the staging directory behind; drop it so it does
* not sit next to the install forever. */
void update_clear_staging(void) {
char *target = NULL;
char *parent = NULL;
if (install_target(&target, &parent) != INSTALL_UNSUPPORTED) {
char *stale = g_build_filename(parent, UPDATE_STAGING_DIR, NULL);
remove_tree(stale);
g_free(stale);
}
g_free(target);
g_free(parent);
}
/* ------------------------------------------------------------------ */
/* Interface state */
/* ------------------------------------------------------------------ */
static void update_set_state(UpdateState state);
static void update_fail(const char *message) {
g_free(up.error);
up.error = g_strdup(message);
update_set_state(UPDATE_FAILED);
}
static void update_refresh_ui(void) {
gboolean busy = up.state == UPDATE_CHECKING ||
up.state == UPDATE_DOWNLOADING;
gboolean banner = up.state == UPDATE_AVAILABLE ||
up.state == UPDATE_DOWNLOADING ||
up.state == UPDATE_STAGED;
const char *status = "";
const char *action = "update_check";
char *owned = NULL;
switch (up.state) {
case UPDATE_IDLE:
case UPDATE_UP_TO_DATE: {
char *mine = short_commit(APP_COMMIT);
owned = g_strdup_printf(tr(up.state == UPDATE_IDLE
? "update_current"
: "update_uptodate"), mine);
status = owned;
g_free(mine);
break;
}
case UPDATE_CHECKING:
status = tr("update_checking");
break;
case UPDATE_AVAILABLE: {
char *newest = short_commit(up.latest);
owned = g_strdup_printf(tr("update_available"), newest);
status = owned;
action = "update_install";
g_free(newest);
break;
}
case UPDATE_DOWNLOADING:
status = tr("update_downloading");
action = "update_install";
break;
case UPDATE_STAGED:
status = tr("update_staged");
action = "update_restart";
break;
case UPDATE_FAILED:
status = up.error ? up.error : tr("update_failed");
break;
}
if (up.status)
gtk_label_set_text(GTK_LABEL(up.status), status);
if (up.action) {
gtk_button_set_label(GTK_BUTTON(up.action), tr(action));
gtk_widget_set_sensitive(up.action, !busy);
}
if (up.progress)
gtk_widget_set_visible(up.progress, up.state == UPDATE_DOWNLOADING);
if (up.banner) {
gtk_widget_set_visible(up.banner, banner);
if (banner) {
gtk_label_set_text(GTK_LABEL(up.banner_label), status);
gtk_button_set_label(GTK_BUTTON(up.banner_action), tr(action));
gtk_widget_set_sensitive(up.banner_action, !busy);
}
}
g_free(owned);
}
static void update_set_state(UpdateState state) {
up.state = state;
update_refresh_ui();
}
/* Called by apply_language() so switching language relabels the banner. */
void update_apply_lang(void) {
update_refresh_ui();
}
/* ------------------------------------------------------------------ */
/* Running curl */
/* ------------------------------------------------------------------ */
/* Spawn curl and hand its stdout to `done`. FALSE means curl itself could not
* be started, the one failure worth its own message. */
static gboolean curl_async(const char *const *argv, GAsyncReadyCallback done) {
GSubprocess *proc;
proc = g_subprocess_newv(argv,
G_SUBPROCESS_FLAGS_STDOUT_PIPE |
G_SUBPROCESS_FLAGS_STDERR_SILENCE,
NULL);
if (!proc)
return FALSE;
g_subprocess_communicate_utf8_async(proc, NULL, NULL, done, NULL);
return TRUE;
}
/* ------------------------------------------------------------------ */
/* Staging and the swap script */
/* ------------------------------------------------------------------ */
/* The macOS asset holds a single .app whose name we should not assume. */
static char *find_app_bundle(const char *dir) {
GDir *d = g_dir_open(dir, 0, NULL);
const char *name;
char *found = NULL;
if (!d)
return NULL;
while (!found && (name = g_dir_read_name(d))) {
if (g_str_has_suffix(name, ".app"))
found = g_build_filename(dir, name, NULL);
}
g_dir_close(d);
return found;
}
/* Each script takes: pid target source staging. They all wait for this
* process to exit, swap the new version in, relaunch and clean up after
* themselves. Rolling back on a failed move keeps a broken half-update from
* leaving the user with nothing to start. */
static const char *swap_script_body(InstallKind kind) {
switch (kind) {
case INSTALL_MACOS_BUNDLE:
return "#!/bin/sh\n"
"pid=$1; target=$2; source=$3; staging=$4\n"
"n=0\n"
"while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n"
" sleep 0.2; n=$((n+1))\n"
"done\n"
"rm -rf \"$target.old\"\n"
"mv \"$target\" \"$target.old\" 2>/dev/null\n"
"if mv \"$source\" \"$target\" 2>/dev/null; then\n"
" xattr -cr \"$target\" 2>/dev/null\n"
" rm -rf \"$target.old\"\n"
"else\n"
" mv \"$target.old\" \"$target\" 2>/dev/null\n"
"fi\n"
"open -n \"$target\"\n"
"rm -rf \"$staging\"\n"
"rm -f \"$0\"\n";
case INSTALL_APPIMAGE:
return "#!/bin/sh\n"
"pid=$1; target=$2; source=$3; staging=$4\n"
"n=0\n"
"while kill -0 \"$pid\" 2>/dev/null && [ $n -lt 600 ]; do\n"
" sleep 0.2; n=$((n+1))\n"
"done\n"
"if mv \"$source\" \"$target\" 2>/dev/null; then\n"
" chmod +x \"$target\"\n"
"fi\n"
"\"$target\" >/dev/null 2>&1 &\n"
"rm -rf \"$staging\"\n"
"rm -f \"$0\"\n";
case INSTALL_WINDOWS_DIR:
/* Copies over the install without mirroring, so the progress files
* kept inside it survive. ping is the sleep that works without a
* console window. */
return "@echo off\r\n"
"set PID=%~1\r\n"
"set TARGET=%~2\r\n"
"set SOURCE=%~3\r\n"
"set STAGING=%~4\r\n"
":wait\r\n"
"tasklist /FI \"PID eq %PID%\" 2>nul"
" | find \"%PID%\" >nul\r\n"
"if not errorlevel 1 (\r\n"
" ping -n 2 127.0.0.1 >nul\r\n"
" goto wait\r\n"
")\r\n"
"xcopy \"%SOURCE%\\*\" \"%TARGET%\\\" /E /I /Y /Q >nul\r\n"
"start \"\" \"%TARGET%\\maturita.exe\"\r\n"
"rmdir /s /q \"%STAGING%\"\r\n"
"del \"%~f0\"\r\n";
case INSTALL_UNSUPPORTED:
default:
return NULL;
}
}
static gboolean write_swap_script(InstallKind kind) {
const char *body = swap_script_body(kind);
const char *name = kind == INSTALL_WINDOWS_DIR ? "maturita-update.cmd"
: "maturita-update.sh";
if (!body)
return FALSE;
g_free(up.script);
up.script = g_build_filename(g_get_tmp_dir(), name, NULL);
if (!g_file_set_contents(up.script, body, -1, NULL))
return FALSE;
g_chmod(up.script, 0755);
return TRUE;
}
/* Unpack the asset and write the swap script. Nothing outside the staging
* directory is touched until the user restarts. */
static void install_staged(void) {
char *target = NULL;
char *parent = NULL;
InstallKind kind = install_target(&target, &parent);
char *source = NULL;
gboolean ok;
if (kind == INSTALL_APPIMAGE) {
/* The asset already is the executable; there is nothing to unpack. */
source = g_strdup(up.archive);
ok = TRUE;
} else {
char *unpacked = g_build_filename(up.staging, "new", NULL);
const char *argv[] = {"tar", "-xf", up.archive, "-C", unpacked, NULL};
GSubprocess *proc;
/* bsdtar reads zip archives and ships with both macOS and Windows 10
* and later, so no separate unzip tool is needed. */
ok = g_mkdir_with_parents(unpacked, 0755) == 0;
if (ok) {
proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_STDERR_SILENCE,
NULL);
ok = proc && g_subprocess_wait_check(proc, NULL, NULL);
g_clear_object(&proc);
}
if (ok && kind == INSTALL_MACOS_BUNDLE) {
source = find_app_bundle(unpacked);
ok = source != NULL;
} else if (ok) {
source = g_strdup(unpacked);
}
g_free(unpacked);
}
if (ok)
ok = write_swap_script(kind);
g_free(up.source);
up.source = source;
g_free(target);
g_free(parent);
if (ok)
update_set_state(UPDATE_STAGED);
else
update_fail(tr("update_err_stage"));
}
/* ------------------------------------------------------------------ */
/* Download */
/* ------------------------------------------------------------------ */
/* curl writes straight to disk, so the partial file's size is the progress. */
static gboolean download_poll(gpointer data) {
GStatBuf st;
(void)data;
if (up.state != UPDATE_DOWNLOADING) {
up.poll_id = 0;
return G_SOURCE_REMOVE;
}
if (up.progress && up.archive && g_stat(up.archive, &st) == 0) {
if (up.total > 0)
gtk_progress_bar_set_fraction(
GTK_PROGRESS_BAR(up.progress),
CLAMP((double)st.st_size / (double)up.total, 0.0, 1.0));
else
gtk_progress_bar_pulse(GTK_PROGRESS_BAR(up.progress));
}
return G_SOURCE_CONTINUE;
}
static void download_done(GObject *src, GAsyncResult *res, gpointer data) {
GSubprocess *proc = G_SUBPROCESS(src);
gboolean ok;
(void)data;
g_subprocess_communicate_utf8_finish(proc, res, NULL, NULL, NULL);
ok = g_subprocess_get_successful(proc);
g_object_unref(proc);
if (up.poll_id) {
g_source_remove(up.poll_id);
up.poll_id = 0;
}
if (!ok) {
update_fail(tr("update_err_download"));
return;
}
install_staged();
}
/* Content-Length of the asset, read from the headers of every redirect hop.
* The last one is the file itself; the hops before it report zero. curl has no
* --write-out variable for this, since a HEAD downloads no body. */
static goffset content_length_from_headers(const char *headers) {
char **lines = g_strsplit(headers ? headers : "", "\n", -1);
goffset total = 0;
for (guint i = 0; lines[i]; i++) {
if (g_ascii_strncasecmp(lines[i], "content-length:", 15) == 0) {
goffset v = (goffset)g_ascii_strtoll(lines[i] + 15, NULL, 10);
if (v > 0)
total = v;
}
}
g_strfreev(lines);
return total;
}
/* The asset size only makes the progress bar meaningful, so a failure here
* costs nothing: the bar falls back to pulsing. */
static void size_probe_done(GObject *src, GAsyncResult *res, gpointer data) {
GSubprocess *proc = G_SUBPROCESS(src);
char *out = NULL;
char *url;
const char *argv[8];
(void)data;
g_subprocess_communicate_utf8_finish(proc, res, &out, NULL, NULL);
up.total = content_length_from_headers(out);
g_free(out);
g_object_unref(proc);
if (up.state != UPDATE_DOWNLOADING)
return;
url = g_strconcat(UPDATE_BASE_URL, asset_name(), NULL);
argv[0] = "curl";
argv[1] = "-fsSL";
argv[2] = "--max-time";
argv[3] = "1800";
argv[4] = "-o";
argv[5] = up.archive;
argv[6] = url;
argv[7] = NULL;
if (!curl_async(argv, download_done))
update_fail(tr("update_err_curl"));
else
up.poll_id = g_timeout_add(250, download_poll, NULL);
g_free(url);
}
static void update_start_download(void) {
char *target = NULL;
char *parent = NULL;
char *url;
const char *argv[10];
if (install_target(&target, &parent) == INSTALL_UNSUPPORTED ||
!asset_name()) {
g_free(target);
g_free(parent);
update_fail(tr("update_err_unsupported"));
return;
}
/* Staging beside the install keeps the final move on one filesystem, and
* fails here, before anything is downloaded, if we cannot write there. */
g_free(up.staging);
up.staging = g_build_filename(parent, UPDATE_STAGING_DIR, NULL);
remove_tree(up.staging);
g_free(target);
g_free(parent);
if (g_mkdir_with_parents(up.staging, 0755) != 0) {
update_fail(tr("update_err_readonly"));
return;
}
g_free(up.archive);
up.archive = g_build_filename(up.staging, asset_name(), NULL);
up.total = 0;
update_set_state(UPDATE_DOWNLOADING);
/* HEAD the asset with its headers on stdout, to size the progress bar. */
url = g_strconcat(UPDATE_BASE_URL, asset_name(), NULL);
argv[0] = "curl";
argv[1] = "-fsSLI";
argv[2] = "--max-time";
argv[3] = "20";
argv[4] = "-D";
argv[5] = "-";
argv[6] = "-o";
argv[7] = NULL_DEVICE;
argv[8] = url;
argv[9] = NULL;
if (!curl_async(argv, size_probe_done))
update_fail(tr("update_err_curl"));
g_free(url);
}
/* ------------------------------------------------------------------ */
/* Check */
/* ------------------------------------------------------------------ */
/* A failed background check stays silent; only an explicit one reports. */
static void check_giveup(void) {
if (up.interactive)
update_fail(tr("update_err_network"));
else
update_set_state(UPDATE_IDLE);
}
static void check_done(GObject *src, GAsyncResult *res, gpointer data) {
GSubprocess *proc = G_SUBPROCESS(src);
char *out = NULL;
gboolean ok;
(void)data;
g_subprocess_communicate_utf8_finish(proc, res, &out, NULL, NULL);
ok = g_subprocess_get_successful(proc);
g_object_unref(proc);
if (out)
g_strstrip(out);
if (!ok || !out || !*out) {
g_free(out);
check_giveup();
return;
}
g_free(up.latest);
up.latest = out;
if (same_commit(APP_COMMIT, up.latest))
update_set_state(up.interactive ? UPDATE_UP_TO_DATE : UPDATE_IDLE);
else if (update_supported())
update_set_state(UPDATE_AVAILABLE);
else if (up.interactive)
/* A newer build exists, but this copy cannot swap itself out. */
update_fail(tr("update_err_unsupported"));
else
update_set_state(UPDATE_IDLE);
}
void update_check_async(gboolean interactive) {
const char *argv[8];
if (up.state == UPDATE_CHECKING || up.state == UPDATE_DOWNLOADING ||
up.state == UPDATE_STAGED)
return;
up.interactive = interactive;
/* Nothing to compare against without a baked-in commit. */
if (!is_published_build()) {
if (interactive)
update_fail(tr("update_err_devbuild"));
return;
}
update_set_state(UPDATE_CHECKING);
/* The CDN in front of raw.githubusercontent.com caches for a few minutes,
* so ask it not to hand back a stale commit. */
argv[0] = "curl";
argv[1] = "-fsSL";
argv[2] = "--max-time";
argv[3] = "20";
argv[4] = "-H";
argv[5] = "Cache-Control: no-cache";
argv[6] = UPDATE_VERSION_URL;
argv[7] = NULL;
if (!curl_async(argv, check_done)) {
if (interactive)
update_fail(tr("update_err_curl"));
else
update_set_state(UPDATE_IDLE);
}
}
/* ------------------------------------------------------------------ */
/* Restart into the new version */
/* ------------------------------------------------------------------ */
static void update_restart(void) {
char *target = NULL;
char *parent = NULL;
InstallKind kind = install_target(&target, &parent);
char *pid = g_strdup_printf("%ld", update_getpid());
GSubprocess *proc;
const char *argv[8];
int i = 0;
if (kind == INSTALL_UNSUPPORTED || !up.script || !up.source ||
!up.staging) {
g_free(pid);
g_free(target);
g_free(parent);
update_fail(tr("update_err_stage"));
return;
}
#ifdef _WIN32
argv[i++] = "cmd";
argv[i++] = "/c";
#else
argv[i++] = "/bin/sh";
#endif
argv[i++] = up.script;
argv[i++] = pid;
argv[i++] = target;
argv[i++] = up.source;
argv[i++] = up.staging;
argv[i] = NULL;
/* The child outlives us on purpose: it is what puts the new version in
* place once this process is gone. */
proc = g_subprocess_newv(argv, G_SUBPROCESS_FLAGS_NONE, NULL);
g_free(pid);
g_free(target);
g_free(parent);
if (!proc) {
update_fail(tr("update_err_stage"));
return;
}
g_object_unref(proc);
if (main_window) {
GtkApplication *app = gtk_window_get_application(main_window);
if (app)
g_application_quit(G_APPLICATION(app));
else
gtk_window_close(main_window);
}
}
/* ------------------------------------------------------------------ */
/* Widgets */
/* ------------------------------------------------------------------ */
static void on_update_action(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
switch (up.state) {
case UPDATE_AVAILABLE:
update_start_download();
break;
case UPDATE_STAGED:
update_restart();
break;
case UPDATE_DOWNLOADING:
case UPDATE_CHECKING:
break;
default:
update_check_async(TRUE);
break;
}
}
static void on_update_dismiss(GtkButton *btn, gpointer data) {
(void)btn;
(void)data;
if (up.banner)
gtk_widget_set_visible(up.banner, FALSE);
}
/* Strip shown above the page stack once an update is waiting. */
GtkWidget *build_update_banner(void) {
GtkWidget *bar = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
GtkWidget *label = gtk_label_new("");
GtkWidget *action = gtk_button_new_with_label("");
GtkWidget *later = gtk_button_new();
gtk_widget_add_css_class(bar, "update-banner");
gtk_widget_set_visible(bar, FALSE);
gtk_widget_set_hexpand(label, TRUE);
gtk_label_set_xalign(GTK_LABEL(label), 0.0);
gtk_widget_add_css_class(label, "update-banner-text");
gtk_box_append(GTK_BOX(bar), label);
gtk_widget_add_css_class(action, "update-banner-btn");
gtk_widget_set_valign(action, GTK_ALIGN_CENTER);
g_signal_connect(action, "clicked", G_CALLBACK(on_update_action), NULL);
gtk_box_append(GTK_BOX(bar), action);
i18n_bind(later, "update_later", 1);
gtk_widget_add_css_class(later, "update-banner-later");
gtk_widget_set_valign(later, GTK_ALIGN_CENTER);
g_signal_connect(later, "clicked", G_CALLBACK(on_update_dismiss), NULL);
gtk_box_append(GTK_BOX(bar), later);
up.banner = bar;
up.banner_label = label;
up.banner_action = action;
return bar;
}
/* "Updates" section of the settings popover. */
GtkWidget *build_update_box(void) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8);
GtkWidget *status = gtk_label_new("");
GtkWidget *action = gtk_button_new_with_label("");
GtkWidget *progress = gtk_progress_bar_new();
gtk_widget_set_hexpand(status, TRUE);
gtk_label_set_xalign(GTK_LABEL(status), 0.0);
gtk_label_set_wrap(GTK_LABEL(status), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(status), 30);
gtk_widget_add_css_class(status, "update-status");
gtk_box_append(GTK_BOX(row), status);
gtk_widget_add_css_class(action, "update-btn");
gtk_widget_set_valign(action, GTK_ALIGN_CENTER);
g_signal_connect(action, "clicked", G_CALLBACK(on_update_action), NULL);
gtk_box_append(GTK_BOX(row), action);
gtk_box_append(GTK_BOX(box), row);
gtk_widget_add_css_class(progress, "update-progress");
gtk_widget_set_visible(progress, FALSE);
gtk_box_append(GTK_BOX(box), progress);
up.status = status;
up.action = action;
up.progress = progress;
update_refresh_ui();
return box;
}
/* ------------------------------------------------------------------ */
/* Startup */
/* ------------------------------------------------------------------ */
static gboolean update_startup_check(gpointer data) {
(void)data;
update_check_async(FALSE);
return G_SOURCE_REMOVE;
}
/* Leaves the first seconds to the interface, then looks for a new release. */
void update_init(void) {
update_clear_staging();
g_timeout_add_seconds(3, update_startup_check, NULL);
}

333
src/util.c Normal file
View file

@ -0,0 +1,333 @@
#include "maturita.h"
#ifdef _WIN32
#include <windows.h>
#elif defined(__APPLE__)
#include <mach-o/dyld.h>
#include <unistd.h>
#else
#include <unistd.h>
#endif
/* ------------------------------------------------------------------ */
/* Portable data / progress paths */
/* ------------------------------------------------------------------ */
char *app_progress_dir;
char *app_progress_u1;
char *app_progress_u2;
char *app_progress_u3;
char *app_settings_file;
char *app_progress_net;
char *app_progress_hw;
char *app_progress_mluvnice;
char *app_progress_cetba;
static char *dup_dirname(const char *path) {
char *tmp = g_strdup(path);
char *dir;
if (!tmp)
return NULL;
dir = g_path_get_dirname(tmp);
g_free(tmp);
return dir;
}
/* Absolute path of this process's executable, or NULL. */
char *app_executable_path(void) {
#ifdef _WIN32
wchar_t wbuf[MAX_PATH];
DWORD n = GetModuleFileNameW(NULL, wbuf, MAX_PATH);
char *out;
if (n == 0 || n >= MAX_PATH)
return NULL;
out = g_utf16_to_utf8((const gunichar2 *)wbuf, -1, NULL, NULL, NULL);
return out;
#elif defined(__APPLE__)
char *buf;
uint32_t size = 0;
_NSGetExecutablePath(NULL, &size);
if (size == 0)
return NULL;
buf = g_malloc(size);
if (_NSGetExecutablePath(buf, &size) != 0) {
g_free(buf);
return NULL;
}
{
char *real = g_canonicalize_filename(buf, NULL);
g_free(buf);
return real ? real : NULL;
}
#else
return g_file_read_link("/proc/self/exe", NULL);
#endif
}
static gboolean dir_has_stylesheet(const char *dir) {
char *css;
gboolean ok;
if (!dir || !dir[0])
return FALSE;
css = g_build_filename(dir, CSS_FILE, NULL);
ok = g_file_test(css, G_FILE_TEST_IS_REGULAR);
g_free(css);
return ok;
}
static char *macos_resources_dir(const char *exe) {
char *macos_dir;
char *contents;
char *resources;
if (!exe)
return NULL;
macos_dir = dup_dirname(exe);
if (!macos_dir)
return NULL;
if (!g_str_has_suffix(macos_dir, "MacOS")) {
g_free(macos_dir);
return NULL;
}
contents = dup_dirname(macos_dir);
g_free(macos_dir);
if (!contents)
return NULL;
resources = g_build_filename(contents, "Resources", NULL);
g_free(contents);
if (!dir_has_stylesheet(resources)) {
g_free(resources);
return NULL;
}
return resources;
}
/* Directory next to Foo.app when exe is .../Foo.app/Contents/MacOS/maturita. */
static char *macos_app_parent_dir(const char *exe) {
char *macos_dir;
char *contents;
char *app_bundle;
char *parent;
if (!exe)
return NULL;
macos_dir = dup_dirname(exe);
if (!macos_dir || !g_str_has_suffix(macos_dir, "MacOS")) {
g_free(macos_dir);
return NULL;
}
contents = dup_dirname(macos_dir);
g_free(macos_dir);
if (!contents)
return NULL;
app_bundle = dup_dirname(contents);
g_free(contents);
if (!app_bundle)
return NULL;
parent = dup_dirname(app_bundle);
g_free(app_bundle);
return parent;
}
static void set_progress_paths(const char *write_root) {
g_free(app_progress_dir);
g_free(app_progress_u1);
g_free(app_progress_u2);
g_free(app_progress_u3);
g_free(app_settings_file);
g_free(app_progress_net);
g_free(app_progress_hw);
g_free(app_progress_mluvnice);
g_free(app_progress_cetba);
app_progress_dir = g_build_filename(write_root, PROGRESS_DIR_NAME, NULL);
app_progress_u1 = g_build_filename(app_progress_dir, "unit1.conf", NULL);
app_progress_u2 = g_build_filename(app_progress_dir, "unit2.conf", NULL);
app_progress_u3 = g_build_filename(app_progress_dir, "unit3.conf", NULL);
app_settings_file = g_build_filename(app_progress_dir, "settings.conf", NULL);
app_progress_net = g_build_filename(app_progress_dir, "net.conf", NULL);
app_progress_hw = g_build_filename(app_progress_dir, "hw.conf", NULL);
app_progress_mluvnice = g_build_filename(app_progress_dir, "mluvnice.conf", NULL);
app_progress_cetba = g_build_filename(app_progress_dir, "cetba.conf", NULL);
}
/* Resolve read-only asset root, chdir there, and point progress at a writable
* location (next to the AppImage / .app, or beside the binary / source tree). */
void setup_portable_paths(void) {
const char *appdir = g_getenv("APPDIR");
const char *appimage = g_getenv("APPIMAGE");
char *exe = app_executable_path();
char *exe_dir = exe ? dup_dirname(exe) : NULL;
char *data_dir = NULL;
char *write_root = NULL;
char *cwd;
if (appdir && dir_has_stylesheet(appdir))
data_dir = g_strdup(appdir);
if (!data_dir)
data_dir = macos_resources_dir(exe);
if (!data_dir && exe_dir && dir_has_stylesheet(exe_dir))
data_dir = g_strdup(exe_dir);
cwd = g_get_current_dir();
if (!data_dir && dir_has_stylesheet(cwd))
data_dir = g_strdup(cwd);
if (!data_dir && cwd)
data_dir = g_strdup(cwd);
if (appimage && appimage[0])
write_root = dup_dirname(appimage);
if (!write_root)
write_root = macos_app_parent_dir(exe);
if (!write_root && exe_dir)
write_root = g_strdup(exe_dir);
if (!write_root && data_dir)
write_root = g_strdup(data_dir);
if (!write_root)
write_root = g_strdup(".");
set_progress_paths(write_root);
if (data_dir && g_chdir(data_dir) != 0)
g_warning("Could not chdir to data directory '%s'", data_dir);
g_free(cwd);
g_free(write_root);
g_free(data_dir);
g_free(exe_dir);
g_free(exe);
}
char *normalize_answer(const char *input) {
GString *pre = g_string_new(NULL);
GString *out = g_string_new(NULL);
const gchar *down = g_utf8_strdown(input ? input : "", -1);
gchar *decomp;
const gchar *p;
gboolean prev_space = FALSE;
/* German special letters first, so ä/ö/ü/ß keep their two-letter
* keyboard fallbacks (ae/oe/ue/ss). */
for (p = down; *p; p = g_utf8_next_char(p)) {
gunichar ch = g_utf8_get_char(p);
switch (ch) {
case 0x00e4: g_string_append(pre, "ae"); break; /* ä */
case 0x00f6: g_string_append(pre, "oe"); break; /* ö */
case 0x00fc: g_string_append(pre, "ue"); break; /* ü */
case 0x00df: g_string_append(pre, "ss"); break; /* ß */
default: g_string_append_unichar(pre, ch); break;
}
}
/* Decompose and drop combining accent marks so Czech letters such as
* é/ě/ř/ů/ž/š/č are treated as their base letters. */
decomp = g_utf8_normalize(pre->str, -1, G_NORMALIZE_NFD);
if (!decomp)
decomp = g_strdup(pre->str);
for (p = decomp; *p; p = g_utf8_next_char(p)) {
gunichar ch = g_utf8_get_char(p);
if (g_unichar_combining_class(ch) != 0)
continue; /* accent mark */
if (g_unichar_isspace(ch)) {
if (out->len > 0 && !prev_space)
g_string_append_c(out, ' ');
prev_space = TRUE;
continue;
}
if (!g_unichar_isalnum(ch))
continue; /* ignore punctuation and symbols */
prev_space = FALSE;
g_string_append_unichar(out, ch);
}
if (out->len > 0 && out->str[out->len - 1] == ' ')
g_string_truncate(out, out->len - 1);
g_free(decomp);
g_free((gpointer)down);
g_string_free(pre, TRUE);
return g_string_free(out, FALSE);
}
void shuffle_indices(int *arr, int n) {
for (int i = n - 1; i > 0; i--) {
int j = g_random_int_range(0, i + 1);
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
void flow_clear(GtkFlowBox *fb) {
GtkWidget *c = gtk_widget_get_first_child(GTK_WIDGET(fb));
while (c) {
GtkWidget *next = gtk_widget_get_next_sibling(c);
gtk_flow_box_remove(fb, c);
c = next;
}
}
void answer_mark(GtkWidget *widget, gboolean ok) {
if (ok) {
gtk_widget_remove_css_class(widget, "answer-wrong");
gtk_widget_add_css_class(widget, "answer-ok");
} else {
gtk_widget_remove_css_class(widget, "answer-ok");
gtk_widget_add_css_class(widget, "answer-wrong");
}
}
void set_feedback(GtkWidget *label, gboolean ok, const char *text) {
gtk_widget_remove_css_class(label, "feedback-ok");
gtk_widget_remove_css_class(label, "feedback-err");
gtk_widget_remove_css_class(label, "feedback-warn");
gtk_widget_add_css_class(label, ok ? "feedback-ok" : "feedback-err");
gtk_label_set_text(GTK_LABEL(label), text);
}
/* Yellow "almost there" note: close to a correct answer but incomplete. */
void set_feedback_warn(GtkWidget *label, const char *text) {
gtk_widget_remove_css_class(label, "feedback-ok");
gtk_widget_remove_css_class(label, "feedback-err");
gtk_widget_remove_css_class(label, "feedback-warn");
gtk_widget_add_css_class(label, "feedback-warn");
gtk_label_set_text(GTK_LABEL(label), text);
}
/* True if `s` contains any German special letter (ä, ö, ü or ß). */
gboolean text_has_german_umlaut(const char *s) {
const char *p = s ? s : "";
while (*p) {
gunichar c = g_utf8_get_char(p);
if (c == 0x00e4 || c == 0x00f6 || c == 0x00fc || c == 0x00df)
return TRUE;
p = g_utf8_next_char(p);
}
return FALSE;
}
/* Small bilingual note shown under the exercise title whenever the answer
* the student has to type may contain a German special letter. */
void add_umlaut_note(GtkWidget *body) {
GtkWidget *n = gtk_label_new(NULL);
i18n_bind(n, "hint_umlauts", 0);
gtk_widget_set_halign(n, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(n), TRUE);
gtk_widget_set_margin_bottom(n, 2);
gtk_widget_add_css_class(n, "hint");
gtk_box_append(GTK_BOX(body), n);
}