Graduately/exercise_common.c
pepaskopec-blip ae56a1c1da Rework the interface into a consistent visual system.
Replace the accumulated per-widget decoration with short, documented
scales for type, weight, radius and motion. Fills are flat and elevation
is neutral, so accent colour marks state instead of decorating: the
gradients on buttons, pills, nodes and progress bars are gone, along with
the coloured glow shadows and the triple ring on the current unit.

Drop draw_node_halo() and the gradient aura on the learning path, which
painted the same bloom in the cairo layer.

Give the welcome page the app icon, a single-colour heading and an
unboxed paragraph. Restore keyboard focus rings, which were suppressed
everywhere, and clamp exercise bodies to a readable measure on wide
windows.

Fix two layout bugs found on the way: the statistics metric cards were
unequal because hexpand only shares leftover space, and a fraction of
zero still painted a stub of accent colour because GTK gives the progress
node a minimum width.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 14:17:34 +02:00

857 lines
27 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#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áš?",
};