Graduately/src/english.c
pepaskopec-blip 6fbfed86f8 Keep the listening text on screen and tuck speed and volume into a short column.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-09 18:11:35 +02:00

1647 lines
52 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 "graduately.h"
#include <string.h>
#ifdef __APPLE__
#include <objc/message.h>
#include <objc/runtime.h>
#endif
#ifdef G_OS_UNIX
#include <signal.h>
#include <sys/wait.h>
#endif
/* English across four gymnasium years (RVP G, foreign language, maturita
* skills). Lesson text lives in english_lessons.inc. */
#define EN_YEARS 4
#define EN_N 8
#include "english_lessons.inc"
typedef struct {
const ChoiceQ *q;
GtkToggleButton *tb[4];
GtkWidget *hint;
} EnPick;
typedef struct {
int year;
int index;
const EnLesson *lesson;
void (*on_done)(int year, int index);
GtkWidget *feedback;
GtkWidget *model;
GArray *picks;
GArray *entries;
GArray *gap_hints;
GtkWidget *essay;
GtkWidget *play_note;
GtkWidget *play_btn;
gboolean played;
gboolean busy;
gboolean paused;
gboolean fallback;
gboolean restarting;
gboolean voice_dirty;
gboolean heard;
double speed;
double gain;
const char *voice;
GPid pid;
guint apply_src;
guint poll;
#ifdef __APPLE__
id synth;
#endif
} EnEx;
typedef struct {
int year;
GtkWidget *scroll;
GtkWidget *fixed;
GtkWidget *rail;
GtkWidget *nodes[EN_N];
GtkWidget *labels[EN_N];
double cx[EN_N];
double cy[EN_N];
int cols, rows, cw, ch;
guint idle;
double last_avail;
int last_cols, last_rows, last_cw, last_ch;
cairo_surface_t *cache;
int cache_w, cache_h;
} EngMap;
static NetLesson en_rt[EN_YEARS][EN_N];
static EngMap en_maps[EN_YEARS];
static char en_map_page[EN_YEARS][16];
static char en_unit_page[EN_YEARS][EN_N][16];
static char en_ex_page[EN_YEARS][EN_N][16];
static char en_title_key[EN_YEARS][EN_N][16];
static char en_sub_key[EN_YEARS][EN_N][20];
static gboolean en_ready;
static void en_prepare(void) {
if (en_ready)
return;
en_ready = TRUE;
for (int y = 0; y < EN_YEARS; y++) {
g_snprintf(en_map_page[y], sizeof en_map_page[y], "en%dmap", y + 1);
for (int i = 0; i < EN_N; i++) {
g_snprintf(en_unit_page[y][i], sizeof en_unit_page[y][i],
"en%dunit%d", y + 1, i + 1);
g_snprintf(en_ex_page[y][i], sizeof en_ex_page[y][i],
"en%dex%d", y + 1, i + 1);
g_snprintf(en_title_key[y][i], sizeof en_title_key[y][i],
"en_y%d_u%d", y + 1, i + 1);
g_snprintf(en_sub_key[y][i], sizeof en_sub_key[y][i],
"en_y%d_u%d_sub", y + 1, i + 1);
en_rt[y][i].unit_page = en_unit_page[y][i];
en_rt[y][i].ex_page = en_ex_page[y][i];
en_rt[y][i].n_slides = (guint)en_lessons[y][i].n_slides;
}
}
}
static void en_save_progress(void) {
GKeyFile *kf = g_key_file_new();
gchar *data;
GError *err = NULL;
for (int y = 0; y < EN_YEARS; y++) {
for (int i = 0; i < EN_N; i++) {
char key[8];
g_snprintf(key, sizeof key, "%d-%d", y + 1, i + 1);
g_key_file_set_boolean(kf, "done", key, en_rt[y][i].done);
}
}
data = g_key_file_to_data(kf, NULL, NULL);
if (!g_file_set_contents(PROGRESS_EN, data, -1, &err)) {
g_warning("english progress: %s", err->message);
g_error_free(err);
}
g_free(data);
g_key_file_free(kf);
}
void en_load_progress(void) {
GKeyFile *kf = g_key_file_new();
GError *err = NULL;
en_prepare();
if (!g_key_file_load_from_file(kf, PROGRESS_EN, G_KEY_FILE_NONE, &err)) {
if (err)
g_error_free(err);
g_key_file_free(kf);
return;
}
for (int y = 0; y < EN_YEARS; y++) {
for (int i = 0; i < EN_N; i++) {
char key[8];
g_snprintf(key, sizeof key, "%d-%d", y + 1, i + 1);
en_rt[y][i].done = g_key_file_get_boolean(kf, "done", key, NULL);
}
}
g_key_file_free(kf);
}
static void en_refresh(void) {
for (int y = 0; y < EN_YEARS; y++) {
for (int i = 0; i < EN_N; i++) {
GtkWidget *node = en_maps[y].nodes[i];
GtkWidget *icon = en_rt[y][i].done_icon;
if (!node)
continue;
if (en_rt[y][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);
}
}
}
}
static void mark_en_done(int year, int index) {
if (year < 0 || year >= EN_YEARS || index < 0 || index >= EN_N)
return;
if (en_rt[year][index].done)
return;
en_rt[year][index].done = TRUE;
en_save_progress();
en_refresh();
refresh_stats_ui();
}
void progress_for_en(ProgressSum *out) {
out->total_ex += EN_YEARS * EN_N;
out->open_units += EN_YEARS * EN_N;
for (int y = 0; y < EN_YEARS; y++) {
for (int i = 0; i < EN_N; i++) {
if (en_rt[y][i].done) {
out->done_ex++;
out->done_units++;
}
}
}
}
/* ------------------------------------------------------------------ */
/* Path */
/* ------------------------------------------------------------------ */
static void en_point(EngMap *m, 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 >= EN_N - 1) { k = EN_N - 2; u = 1.0; }
p1x = m->cx[k]; p1y = m->cy[k];
p2x = m->cx[k + 1]; p2y = m->cy[k + 1];
if (k - 1 >= 0) { p0x = m->cx[k - 1]; p0y = m->cy[k - 1]; }
else { p0x = p1x - (p2x - p1x); p0y = p1y - (p2y - p1y); }
if (k + 2 < EN_N) { p3x = m->cx[k + 2]; p3y = m->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);
}
static void en_path(EngMap *m, cairo_t *cr, double t0, double t1) {
int n = (int)((t1 - t0) * 24.0);
double x, y;
if (n < 1)
n = 1;
en_point(m, t0, &x, &y);
cairo_move_to(cr, x, y);
for (int s = 1; s <= n; s++) {
en_point(m, t0 + (t1 - t0) * (double)s / (double)n, &x, &y);
cairo_line_to(cr, x, y);
}
}
static void en_geometry(EngMap *m, double avail) {
const double phase = 2.0 * G_PI * 1.7 / (double)(EN_N - 1);
int rows;
for (rows = 1; rows <= EN_N; rows++) {
int cols = (EN_N + rows - 1) / rows;
if (2.0 * ROAD_MX + (cols - 1) * PATH_SPAC <= avail + 1.0)
break;
}
if (rows > EN_N)
rows = EN_N;
m->rows = rows;
m->cols = (EN_N + rows - 1) / rows;
if (m->cols < 1)
m->cols = 1;
m->cw = (int)(2.0 * ROAD_MX + (m->cols - 1) * PATH_SPAC);
m->ch = (int)(2.0 * ROAD_MY + (rows - 1) * ROAD_GAP);
for (int r = 0; r < rows; r++) {
int base = r * m->cols;
int len = MIN(m->cols, EN_N - base);
int fwd = (r % 2) == 0;
for (int c = 0; c < len; c++) {
int i = base + c;
int cc = fwd ? c : (m->cols - 1 - c);
m->cx[i] = ROAD_MX + cc * PATH_SPAC;
m->cy[i] = ROAD_MY + r * ROAD_GAP
+ ROAD_WAVE * sin((double)i * phase);
}
}
}
static void en_apply_layout(EngMap *m) {
if (!m->fixed)
return;
if (m->cols == m->last_cols && m->rows == m->last_rows &&
m->cw == m->last_cw && m->ch == m->last_ch)
return;
m->last_cols = m->cols;
m->last_rows = m->rows;
m->last_cw = m->cw;
m->last_ch = m->ch;
if (m->cache) {
cairo_surface_destroy(m->cache);
m->cache = NULL;
}
for (int i = 0; i < EN_N; i++) {
if (!m->nodes[i] || !m->labels[i])
continue;
gtk_fixed_move(GTK_FIXED(m->fixed), m->nodes[i],
(int)(m->cx[i] - NODE_SIZE / 2.0),
(int)(m->cy[i] - NODE_SIZE / 2.0));
gtk_fixed_move(GTK_FIXED(m->fixed), m->labels[i],
(int)(m->cx[i] - (PATH_SPAC - 20.0) / 2.0),
(int)(m->cy[i] + NODE_SIZE / 2.0 + 10.0));
}
gtk_widget_set_size_request(m->fixed, m->cw, m->ch);
gtk_widget_set_size_request(m->rail, m->cw, m->ch);
gtk_fixed_move(GTK_FIXED(m->fixed), m->rail, 0, 0);
gtk_widget_queue_draw(m->rail);
}
static void en_relayout(EngMap *m) {
GtkAdjustment *hadj;
double avail;
if (!m->scroll)
return;
hadj = gtk_scrolled_window_get_hadjustment(GTK_SCROLLED_WINDOW(m->scroll));
avail = gtk_adjustment_get_page_size(hadj);
if (avail < 1.0)
return;
if (fabs(avail - m->last_avail) < 1.0)
return;
m->last_avail = avail;
en_geometry(m, avail);
en_apply_layout(m);
}
static gboolean en_relayout_idle(gpointer data) {
EngMap *m = data;
m->idle = 0;
en_relayout(m);
return G_SOURCE_REMOVE;
}
static void en_adjust_notify(GtkAdjustment *adj, GParamSpec *ps, gpointer data) {
EngMap *m = data;
(void)adj;
(void)ps;
if (m->idle == 0)
m->idle = g_idle_add(en_relayout_idle, m);
}
static void en_render_rail(EngMap *m, cairo_t *cr) {
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);
en_path(m, cr, 0.0, (double)(EN_N - 1));
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);
en_path(m, cr, 0.0, (double)(EN_N - 1));
cairo_stroke(cr);
cairo_set_dash(cr, NULL, 0, 0.0);
}
static void en_draw_rail(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
EngMap *m = data;
(void)area;
(void)width;
(void)height;
if (!m->cache || m->cache_w != m->cw || m->cache_h != m->ch) {
cairo_t *rcr;
if (m->cache)
cairo_surface_destroy(m->cache);
m->cache_w = m->cw > 0 ? m->cw : 1;
m->cache_h = m->ch > 0 ? m->ch : 1;
m->cache = cairo_image_surface_create(CAIRO_FORMAT_ARGB32,
m->cache_w, m->cache_h);
rcr = cairo_create(m->cache);
en_render_rail(m, rcr);
cairo_destroy(rcr);
}
cairo_set_source_surface(cr, m->cache, 0, 0);
cairo_paint(cr);
}
void en_rail_theme_reset(void) {
for (int y = 0; y < EN_YEARS; y++) {
if (en_maps[y].cache) {
cairo_surface_destroy(en_maps[y].cache);
en_maps[y].cache = NULL;
}
if (en_maps[y].rail)
gtk_widget_queue_draw(en_maps[y].rail);
}
}
static void en_open_unit(GtkButton *button, gpointer data) {
NetLesson *L = data;
(void)button;
if (!L)
return;
L->idx = 0;
if (L->stack) {
char name[16];
g_snprintf(name, sizeof name, "slide%u", L->idx);
gtk_stack_set_visible_child_name(GTK_STACK(L->stack), name);
if (L->prev_btn)
gtk_widget_set_sensitive(L->prev_btn, FALSE);
if (L->next_btn)
gtk_button_set_label(GTK_BUTTON(L->next_btn), tr("net_slide_next"));
}
show_page(L->unit_page, 1);
}
static void en_add_node(EngMap *m, GtkFixed *fixed, int index) {
GtkWidget *card;
GtkWidget *vbox;
GtkWidget *number;
GtkWidget *icon;
GtkWidget *name;
char *text;
card = gtk_button_new();
gtk_widget_add_css_class(card, "unit-node");
gtk_widget_add_css_class(card, "current");
gtk_widget_set_can_focus(card, FALSE);
gtk_widget_set_size_request(card, (int)NODE_SIZE, (int)NODE_SIZE);
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", index + 1);
number = gtk_label_new(text);
g_free(text);
gtk_widget_add_css_class(number, "unit-number");
gtk_box_append(GTK_BOX(vbox), number);
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);
en_rt[m->year][index].done_icon = icon;
g_signal_connect(card, "clicked", G_CALLBACK(en_open_unit),
&en_rt[m->year][index]);
m->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");
i18n_bind(name, en_title_key[m->year][index], 0);
m->labels[index] = name;
gtk_fixed_put(fixed, name, 0, 0);
}
static GtkWidget *en_year_button(int year) {
static const char *keys[] = {"en_year1", "en_year2", "en_year3", "en_year4"};
static const char *subs[] = {"en_y1_sub", "en_y2_sub", "en_y3_sub", "en_y4_sub"};
GtkWidget *btn;
GtkWidget *row;
GtkWidget *num;
GtkWidget *texts;
GtkWidget *title;
GtkWidget *sub;
char *num_text;
btn = gtk_button_new();
gtk_widget_add_css_class(btn, "unit-node");
gtk_widget_add_css_class(btn, "current");
gtk_widget_set_can_focus(btn, FALSE);
gtk_widget_set_hexpand(btn, TRUE);
gtk_widget_set_size_request(btn, -1, 112);
row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 14);
gtk_widget_set_margin_start(row, 18);
gtk_widget_set_margin_end(row, 18);
gtk_widget_set_margin_top(row, 8);
gtk_widget_set_margin_bottom(row, 8);
gtk_button_set_child(GTK_BUTTON(btn), row);
num_text = g_strdup_printf("%d", year);
num = gtk_label_new(num_text);
g_free(num_text);
gtk_widget_add_css_class(num, "unit-number");
gtk_widget_set_valign(num, GTK_ALIGN_CENTER);
gtk_box_append(GTK_BOX(row), num);
texts = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
gtk_widget_set_hexpand(texts, TRUE);
gtk_box_append(GTK_BOX(row), texts);
title = gtk_label_new(NULL);
i18n_bind(title, keys[year - 1], 0);
gtk_widget_set_halign(title, GTK_ALIGN_START);
gtk_widget_add_css_class(title, "unit-name");
gtk_box_append(GTK_BOX(texts), title);
sub = gtk_label_new(NULL);
i18n_bind(sub, subs[year - 1], 0);
gtk_widget_set_halign(sub, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(sub), TRUE);
gtk_label_set_xalign(GTK_LABEL(sub), 0);
gtk_widget_add_css_class(sub, "meaning");
gtk_widget_set_visible(sub, TRUE);
gtk_box_append(GTK_BOX(texts), sub);
g_object_set_data_full(G_OBJECT(btn), "target",
g_strdup(en_map_page[year - 1]), g_free);
g_signal_connect(btn, "clicked", G_CALLBACK(on_nav_clicked), NULL);
return btn;
}
GtkWidget *build_enyears_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *list;
en_prepare();
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", "English", "en_years_sub"));
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, 18);
gtk_box_append(GTK_BOX(page), scroll);
list = gtk_box_new(GTK_ORIENTATION_VERTICAL, 12);
gtk_widget_set_halign(list, GTK_ALIGN_CENTER);
gtk_widget_set_size_request(list, 460, -1);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), list);
for (int y = 1; y <= EN_YEARS; y++)
gtk_box_append(GTK_BOX(list), en_year_button(y));
return page;
}
GtkWidget *build_enmap_page(int year) {
EngMap *m;
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
static const char *year_keys[] = {
"en_year1", "en_year2", "en_year3", "en_year4",
};
static const char *sub_keys[] = {
"en_y1_sub", "en_y2_sub", "en_y3_sub", "en_y4_sub",
};
en_prepare();
m = &en_maps[year];
m->year = year;
en_geometry(m, 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("enyears", year_keys[year], sub_keys[year]));
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);
m->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, m->cw, m->ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
m->fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, m->cw, m->ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), en_draw_rail, m, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
m->rail = rail;
for (int i = 0; i < EN_N; i++)
en_add_node(m, 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(en_adjust_notify), m);
g_signal_connect(va, "notify::page-size", G_CALLBACK(en_adjust_notify), m);
en_refresh();
return page;
}
/* ------------------------------------------------------------------ */
/* Slides */
/* ------------------------------------------------------------------ */
void skill_slide_apply(NetLesson *L) {
char name[16];
if (!L || !L->stack)
return;
g_snprintf(name, sizeof name, "slide%u", L->idx);
gtk_stack_set_visible_child_name(GTK_STACK(L->stack), name);
if (L->prev_btn) {
gtk_widget_set_sensitive(L->prev_btn, L->idx > 0);
gtk_button_set_label(GTK_BUTTON(L->prev_btn), tr("net_slide_prev"));
}
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"));
}
static int en_note_w = -1;
static gboolean en_note_ready;
static void en_rescale_notes(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 y = 0; y < EN_YEARS; y++)
for (int i = 0; i < EN_N; i++)
net_rescale_lesson_notes(&en_rt[y][i], body, head, kick);
}
static gboolean en_note_tick(GtkWidget *w, GdkFrameClock *clock, gpointer data) {
int width = gtk_widget_get_allocated_width(w);
(void)clock;
(void)data;
if (width != en_note_w) {
en_note_w = width;
en_rescale_notes(width);
}
return G_SOURCE_CONTINUE;
}
static void en_slide_prev(GtkButton *button, gpointer data) {
NetLesson *L = data;
(void)button;
if (L && L->idx > 0) {
L->idx--;
skill_slide_apply(L);
}
}
static void en_slide_next(GtkButton *button, gpointer data) {
NetLesson *L = data;
(void)button;
if (!L)
return;
if (L->idx + 1 < L->n_slides) {
L->idx++;
skill_slide_apply(L);
} else {
show_page(L->ex_page, 1);
}
}
void en_lessons_apply_lang(void) {
for (int y = 0; y < EN_YEARS; y++)
for (int i = 0; i < EN_N; i++)
skill_slide_apply(&en_rt[y][i]);
}
GtkWidget *skill_unit_page(NetLesson *L, const EnLesson *lesson,
const char *back, const char *title_key,
const char *sub_key) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *nav;
net_lesson_notes_ensure(L);
net_notes_target = L;
L->n_slides = (guint)lesson->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(back, 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);
for (guint s = 0; s < L->n_slides; s++) {
GtkWidget *card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
char name[16];
g_snprintf(name, sizeof name, "slide%u", s);
gtk_widget_set_margin_top(card, 8);
gtk_widget_set_margin_bottom(card, 8);
net_note_kicker(card, lesson->slides[s].kicker);
net_note_title(card, lesson->slides[s].title);
for (int i = 0; i < 8 && lesson->slides[s].line[i]; i++)
net_note_line(card, lesson->slides[s].line[i], FALSE);
if (lesson->slides[s].tip)
net_note_line(card, lesson->slides[s].tip, TRUE);
gtk_stack_add_named(GTK_STACK(L->stack), card, name);
}
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");
gtk_box_append(GTK_BOX(nav), L->prev_btn);
g_signal_connect(L->prev_btn, "clicked", G_CALLBACK(en_slide_prev), L);
L->next_btn = gtk_button_new_with_label(tr("net_slide_next"));
gtk_widget_add_css_class(L->next_btn, "btn-primary");
gtk_box_append(GTK_BOX(nav), L->next_btn);
g_signal_connect(L->next_btn, "clicked", G_CALLBACK(en_slide_next), L);
if (!en_note_ready) {
gtk_widget_add_tick_callback(L->stack, en_note_tick, NULL, NULL);
en_note_ready = TRUE;
}
en_rescale_notes(0);
net_notes_target = NULL;
skill_slide_apply(L);
return page;
}
/* ------------------------------------------------------------------ */
/* Exercise */
/* ------------------------------------------------------------------ */
static GtkWidget *en_heading(GtkWidget *body, const char *key) {
GtkWidget *label = gtk_label_new(NULL);
i18n_bind(label, key, 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(label), TRUE);
gtk_widget_add_css_class(label, "quiz-heading");
gtk_widget_set_margin_top(label, 8);
gtk_box_append(GTK_BOX(body), label);
return label;
}
static GtkWidget *en_passage(GtkWidget *body, const char *text, gboolean hide) {
GtkWidget *label = gtk_label_new(text);
gtk_label_set_wrap(GTK_LABEL(label), TRUE);
gtk_label_set_wrap_mode(GTK_LABEL(label), PANGO_WRAP_WORD_CHAR);
gtk_label_set_selectable(GTK_LABEL(label), TRUE);
gtk_label_set_xalign(GTK_LABEL(label), 0);
gtk_widget_set_halign(label, GTK_ALIGN_START);
gtk_widget_add_css_class(label, "quiz-intro");
gtk_widget_set_visible(label, !hide);
gtk_box_append(GTK_BOX(body), label);
return label;
}
static void en_add_mcq(EnEx *ctx, GtkWidget *body, const ChoiceQ *qs,
const char **hints, int n) {
for (int i = 0; i < n; i++) {
EnPick pick;
GtkWidget *card;
memset(&pick, 0, sizeof pick);
pick.q = &qs[i];
card = mcq_append_question(body, i + 1, &qs[i], pick.tb);
if (hints && hints[i])
pick.hint = meaning_add(card, hints[i]);
g_array_append_val(ctx->picks, pick);
}
}
static int en_word_count(const char *norm) {
int n = 0;
const char *p = norm ? norm : "";
while (*p) {
while (*p == ' ')
p++;
if (!*p)
break;
n++;
while (*p && *p != ' ')
p++;
}
return n;
}
static gboolean en_has_word(const char *norm, const char *word) {
char *key = normalize_answer(word);
size_t n;
const char *p;
gboolean ok = FALSE;
if (!key || !key[0]) {
g_free(key);
return TRUE;
}
n = strlen(key);
for (p = norm; p && (p = strstr(p, key)); p += n) {
gboolean left = p == norm || p[-1] == ' ';
gboolean right = p[n] == '\0' || p[n] == ' ';
if (left && right) {
ok = TRUE;
break;
}
}
g_free(key);
return ok;
}
static void en_sync_play(EnEx *ctx) {
const char *key = "en_play";
if (!ctx->play_btn)
return;
if (ctx->paused)
key = "en_resume";
else if (ctx->busy)
key = "en_pause";
gtk_button_set_label(GTK_BUTTON(ctx->play_btn), tr(key));
}
static void en_start_fallback(EnEx *ctx) {
ctx->fallback = TRUE;
ctx->played = TRUE;
ctx->busy = FALSE;
ctx->paused = FALSE;
if (ctx->play_note) {
gtk_label_set_text(GTK_LABEL(ctx->play_note), tr("en_listen_fallback"));
gtk_widget_set_visible(ctx->play_note, TRUE);
}
en_sync_play(ctx);
}
static gboolean en_launch(EnEx *ctx);
static void en_voice_done(GPid pid, gint status, gpointer data) {
EnEx *ctx = data;
gboolean ok = FALSE;
gboolean restart = ctx->restarting;
g_spawn_close_pid(pid);
if (ctx->pid == pid)
ctx->pid = 0;
if (restart) {
ctx->restarting = FALSE;
en_launch(ctx);
return;
}
#ifdef G_OS_UNIX
ok = WIFEXITED(status) && WEXITSTATUS(status) == 0;
#else
ok = status == 0;
#endif
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
if (!ok)
en_start_fallback(ctx);
}
static gboolean en_spawn(char **argv, GPid *pid) {
GError *err = NULL;
if (!argv[0] || !g_find_program_in_path(argv[0]))
return FALSE;
if (!g_spawn_async(NULL, argv, NULL,
G_SPAWN_SEARCH_PATH | G_SPAWN_DO_NOT_REAP_CHILD,
NULL, NULL, pid, &err)) {
g_clear_error(&err);
return FALSE;
}
return TRUE;
}
#ifdef __APPLE__
static id en_ns(const char *s) {
return ((id (*)(Class, SEL, const char *))objc_msgSend)(
objc_getClass("NSString"), sel_registerName("stringWithUTF8String:"), s);
}
static void en_mac_levels(EnEx *ctx) {
float rate = (float)(180.0 * (ctx->speed > 0.2 ? ctx->speed : 0.9));
float vol = (float)(ctx->gain < 0 ? 0 : (ctx->gain > 1 ? 1 : ctx->gain));
((void (*)(id, SEL, float))objc_msgSend)(
ctx->synth, sel_registerName("setRate:"), rate);
((void (*)(id, SEL, float))objc_msgSend)(
ctx->synth, sel_registerName("setVolume:"), vol);
}
static gboolean en_mac_try_voice(id synth, const char *name) {
return ((BOOL (*)(id, SEL, id))objc_msgSend)(
synth, sel_registerName("setVoice:"), en_ns(name));
}
static void en_mac_choose(id synth, const char *lang) {
static const char *en_voices[] = {
"com.apple.voice.compact.en-GB.Daniel",
"com.apple.speech.synthesis.voice.daniel",
"com.apple.voice.compact.en-US.Samantha",
"com.apple.speech.synthesis.voice.samantha",
NULL
};
static const char *de_voices[] = {
"com.apple.voice.compact.de-DE.Anna",
"com.apple.speech.synthesis.voice.anna",
"com.apple.voice.compact.de-DE.Helena",
"com.apple.speech.synthesis.voice.petra",
NULL
};
gboolean german = lang && g_str_has_prefix(lang, "de");
const char **list = german ? de_voices : en_voices;
const char *needle = german ? "de-DE" : "en-GB";
Class cls;
id voices;
unsigned long n;
for (int i = 0; list[i]; i++) {
if (en_mac_try_voice(synth, list[i]))
return;
}
cls = objc_getClass("NSSpeechSynthesizer");
if (!cls)
return;
voices = ((id (*)(Class, SEL))objc_msgSend)(
cls, sel_registerName("availableVoices"));
if (!voices)
return;
n = ((unsigned long (*)(id, SEL))objc_msgSend)(
voices, sel_registerName("count"));
for (unsigned long i = 0; i < n; i++) {
id name = ((id (*)(id, SEL, unsigned long))objc_msgSend)(
voices, sel_registerName("objectAtIndex:"), i);
const char *utf = ((const char *(*)(id, SEL))objc_msgSend)(
name, sel_registerName("UTF8String"));
if (utf && strstr(utf, needle) && en_mac_try_voice(synth, utf))
return;
}
}
static gboolean en_mac_ensure(EnEx *ctx) {
Class cls;
id obj;
if (ctx->synth)
return TRUE;
cls = objc_getClass("NSSpeechSynthesizer");
if (!cls)
return FALSE;
obj = ((id (*)(Class, SEL))objc_msgSend)(cls, sel_registerName("alloc"));
obj = ((id (*)(id, SEL))objc_msgSend)(obj, sel_registerName("init"));
if (!obj)
return FALSE;
ctx->synth = obj;
en_mac_choose(obj, ctx->voice);
return TRUE;
}
static gboolean en_mac_speaking(EnEx *ctx) {
if (!ctx->synth)
return FALSE;
return ((BOOL (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("isSpeaking"));
}
static void en_mac_stop(EnEx *ctx) {
if (ctx->synth)
((void (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("stopSpeaking"));
}
static gboolean en_mac_start(EnEx *ctx) {
if (!en_mac_ensure(ctx))
return FALSE;
en_mac_levels(ctx);
ctx->heard = FALSE;
return ((BOOL (*)(id, SEL, id))objc_msgSend)(
ctx->synth, sel_registerName("startSpeakingString:"),
en_ns(ctx->lesson->listening));
}
static void en_mac_pause(EnEx *ctx) {
((BOOL (*)(id, SEL, long))objc_msgSend)(
ctx->synth, sel_registerName("pauseSpeakingAtBoundary:"), 1L);
}
static void en_mac_resume(EnEx *ctx) {
if (ctx->voice_dirty) {
ctx->voice_dirty = FALSE;
en_mac_stop(ctx);
en_mac_start(ctx);
return;
}
((BOOL (*)(id, SEL))objc_msgSend)(
ctx->synth, sel_registerName("continueSpeaking"));
}
static gboolean en_poll_speech(gpointer data) {
EnEx *ctx = data;
if (ctx->paused || ctx->restarting)
return G_SOURCE_CONTINUE;
if (en_mac_speaking(ctx)) {
ctx->heard = TRUE;
return G_SOURCE_CONTINUE;
}
if (!ctx->heard)
return G_SOURCE_CONTINUE;
ctx->poll = 0;
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
return G_SOURCE_REMOVE;
}
#endif
static gboolean en_launch(EnEx *ctx) {
char *text = (char *)ctx->lesson->listening;
double speed = ctx->speed > 0.2 ? ctx->speed : 0.9;
char rate[16];
char amp[16];
ctx->played = TRUE;
ctx->busy = TRUE;
ctx->paused = FALSE;
ctx->voice_dirty = FALSE;
#ifdef __APPLE__
if (en_mac_start(ctx)) {
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return TRUE;
}
#endif
g_snprintf(rate, sizeof rate, "%d", (int)(160.0 * speed));
g_snprintf(amp, sizeof amp, "%d", (int)(ctx->gain * 200.0));
{
const char *voice = ctx->voice && ctx->voice[0] ? ctx->voice : "en";
char *a[] = {"espeak-ng", "-v", (char *)voice, "-s", rate, "-a", amp, text, NULL};
char *b[] = {"espeak", "-v", (char *)voice, "-s", rate, "-a", amp, text, NULL};
#ifdef __APPLE__
char *c_en[] = {"say", "-r", rate, text, NULL};
char *c_de[] = {"say", "-v", "Anna", "-r", rate, text, NULL};
char **tries[] = {g_str_has_prefix(voice, "de") ? c_de : c_en};
int n = 1;
#else
char *c[] = {"spd-say", "-l", (char *)voice, "-r", rate, text, NULL};
char **tries[] = {a, b, c};
int n = 3;
#endif
(void)a;
(void)b;
for (int i = 0; i < n; i++) {
if (en_spawn(tries[i], &ctx->pid)) {
g_child_watch_add(ctx->pid, en_voice_done, ctx);
en_sync_play(ctx);
return TRUE;
}
}
}
ctx->busy = FALSE;
en_start_fallback(ctx);
return FALSE;
}
static void en_pause_voice(EnEx *ctx) {
ctx->paused = TRUE;
#ifdef __APPLE__
if (ctx->synth && en_mac_speaking(ctx)) {
en_mac_pause(ctx);
en_sync_play(ctx);
return;
}
#endif
#ifdef G_OS_UNIX
if (ctx->pid) {
kill((pid_t)ctx->pid, SIGSTOP);
en_sync_play(ctx);
return;
}
#endif
en_sync_play(ctx);
}
static void en_resume_voice(EnEx *ctx) {
ctx->paused = FALSE;
#ifdef __APPLE__
if (ctx->synth) {
en_mac_resume(ctx);
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return;
}
#endif
if (ctx->voice_dirty) {
ctx->voice_dirty = FALSE;
if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
kill((pid_t)ctx->pid, SIGKILL);
#endif
}
en_sync_play(ctx);
return;
}
#ifdef G_OS_UNIX
if (ctx->pid) {
kill((pid_t)ctx->pid, SIGCONT);
en_sync_play(ctx);
return;
}
#endif
en_sync_play(ctx);
}
static void en_restart_voice(EnEx *ctx) {
ctx->voice_dirty = FALSE;
ctx->paused = FALSE;
#ifdef __APPLE__
if (ctx->synth) {
ctx->restarting = TRUE;
en_mac_stop(ctx);
ctx->restarting = FALSE;
en_mac_start(ctx);
if (!ctx->poll)
ctx->poll = g_timeout_add(200, en_poll_speech, ctx);
en_sync_play(ctx);
return;
}
#endif
if (ctx->fallback)
return;
if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
kill((pid_t)ctx->pid, SIGKILL);
#endif
}
}
static gboolean en_apply_voice(gpointer data) {
EnEx *ctx = data;
ctx->apply_src = 0;
if (ctx->busy && !ctx->paused)
en_restart_voice(ctx);
return G_SOURCE_REMOVE;
}
static void en_level_changed(GtkRange *range, gpointer data) {
EnEx *ctx = data;
const char *which = g_object_get_data(G_OBJECT(range), "en-level");
if (g_strcmp0(which, "speed") == 0)
ctx->speed = gtk_range_get_value(range);
else
ctx->gain = gtk_range_get_value(range) / 100.0;
ctx->voice_dirty = TRUE;
if (ctx->apply_src)
g_source_remove(ctx->apply_src);
ctx->apply_src = g_timeout_add(180, en_apply_voice, ctx);
}
static void en_play_clicked(GtkButton *button, gpointer data) {
EnEx *ctx = data;
(void)button;
if (!ctx->lesson->listening)
return;
ctx->played = TRUE;
if (!ctx->busy) {
en_launch(ctx);
return;
}
if (ctx->paused)
en_resume_voice(ctx);
else
en_pause_voice(ctx);
}
static char *en_format_level(GtkScale *scale, double value, gpointer data) {
const char *which = g_object_get_data(G_OBJECT(scale), "en-level");
(void)data;
if (g_strcmp0(which, "speed") == 0)
return g_strdup_printf("%.1f×", value);
return g_strdup_printf("%.0f %%", value);
}
static GtkWidget *en_level_control(EnEx *ctx, const char *key, const char *which,
double min, double max, double value) {
GtkWidget *box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 2);
GtkWidget *lab = gtk_label_new(NULL);
GtkWidget *scale = gtk_scale_new_with_range(GTK_ORIENTATION_HORIZONTAL,
min, max, max > 10 ? 1 : 0.1);
i18n_bind(lab, key, 0);
gtk_widget_add_css_class(lab, "listen-caption");
gtk_label_set_xalign(GTK_LABEL(lab), 0);
gtk_widget_set_halign(lab, GTK_ALIGN_START);
g_object_set_data(G_OBJECT(scale), "en-level", (gpointer)which);
gtk_scale_set_format_value_func(GTK_SCALE(scale), en_format_level, NULL, NULL);
gtk_scale_set_draw_value(GTK_SCALE(scale), TRUE);
gtk_scale_set_value_pos(GTK_SCALE(scale), GTK_POS_RIGHT);
gtk_range_set_value(GTK_RANGE(scale), value);
gtk_widget_add_css_class(scale, "listen-scale");
gtk_widget_set_size_request(scale, 156, -1);
gtk_widget_set_hexpand(scale, FALSE);
gtk_widget_set_halign(scale, GTK_ALIGN_FILL);
g_signal_connect(scale, "value-changed", G_CALLBACK(en_level_changed), ctx);
gtk_box_append(GTK_BOX(box), lab);
gtk_box_append(GTK_BOX(box), scale);
gtk_widget_set_hexpand(box, FALSE);
gtk_widget_set_halign(box, GTK_ALIGN_FILL);
return box;
}
/* Transcript stays on screen. Speed and volume sit in a short column
* beside it, so the sliders do not run the width of the page. */
static void en_add_listen(EnEx *ctx, GtkWidget *parent) {
GtkWidget *card;
GtkWidget *text_col;
GtkWidget *script;
GtkWidget *controls;
GtkWidget *play;
GtkWidget *note;
en_heading(parent, "en_sec_listen");
note = gtk_label_new(NULL);
i18n_bind(note, "en_listen_note", 0);
gtk_label_set_wrap(GTK_LABEL(note), TRUE);
gtk_label_set_xalign(GTK_LABEL(note), 0);
gtk_widget_set_halign(note, GTK_ALIGN_START);
gtk_widget_add_css_class(note, "quiz-intro");
gtk_box_append(GTK_BOX(parent), note);
card = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 22);
gtk_widget_add_css_class(card, "quiz-card");
gtk_widget_add_css_class(card, "listen-card");
controls = gtk_box_new(GTK_ORIENTATION_VERTICAL, 10);
gtk_widget_set_hexpand(controls, FALSE);
gtk_widget_set_halign(controls, GTK_ALIGN_START);
gtk_widget_set_valign(controls, GTK_ALIGN_START);
gtk_widget_set_size_request(controls, 168, -1);
ctx->speed = 0.9;
ctx->gain = 1.0;
play = gtk_button_new_with_label(tr("en_play"));
gtk_widget_add_css_class(play, "btn-primary");
gtk_widget_set_hexpand(play, TRUE);
gtk_widget_set_halign(play, GTK_ALIGN_FILL);
ctx->play_btn = play;
g_signal_connect(play, "clicked", G_CALLBACK(en_play_clicked), ctx);
gtk_box_append(GTK_BOX(controls), play);
gtk_box_append(GTK_BOX(controls),
en_level_control(ctx, "en_speed", "speed",
0.6, 1.5, ctx->speed));
gtk_box_append(GTK_BOX(controls),
en_level_control(ctx, "en_volume", "volume",
0, 100, ctx->gain * 100.0));
text_col = gtk_box_new(GTK_ORIENTATION_VERTICAL, 8);
gtk_widget_set_hexpand(text_col, TRUE);
gtk_widget_set_valign(text_col, GTK_ALIGN_CENTER);
script = gtk_label_new(ctx->lesson->listening);
gtk_label_set_wrap(GTK_LABEL(script), TRUE);
gtk_label_set_wrap_mode(GTK_LABEL(script), PANGO_WRAP_WORD_CHAR);
gtk_label_set_width_chars(GTK_LABEL(script), 36);
gtk_label_set_max_width_chars(GTK_LABEL(script), 72);
gtk_label_set_selectable(GTK_LABEL(script), TRUE);
gtk_label_set_xalign(GTK_LABEL(script), 0);
gtk_widget_set_halign(script, GTK_ALIGN_FILL);
gtk_widget_set_hexpand(script, TRUE);
gtk_widget_add_css_class(script, "listen-script");
gtk_box_append(GTK_BOX(text_col), script);
ctx->play_note = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(ctx->play_note), TRUE);
gtk_label_set_xalign(GTK_LABEL(ctx->play_note), 0);
gtk_widget_set_halign(ctx->play_note, GTK_ALIGN_START);
gtk_widget_add_css_class(ctx->play_note, "meaning");
gtk_widget_set_visible(ctx->play_note, FALSE);
gtk_box_append(GTK_BOX(text_col), ctx->play_note);
gtk_box_append(GTK_BOX(card), controls);
gtk_box_append(GTK_BOX(card), text_col);
gtk_box_append(GTK_BOX(parent), card);
}
static void en_check(GtkButton *button, gpointer data) {
EnEx *ctx = data;
int mcq_ok = 0;
int gap_ok = 0;
gboolean write_ok = TRUE;
gboolean all;
(void)button;
if (ctx->lesson->listening && !ctx->played) {
set_feedback(ctx->feedback, FALSE, tr("en_listen_first"));
return;
}
for (guint i = 0; i < ctx->picks->len; i++) {
EnPick *p = &g_array_index(ctx->picks, EnPick, i);
int active = -1;
for (int o = 0; o < p->q->n_options; o++) {
gtk_widget_remove_css_class(GTK_WIDGET(p->tb[o]), "ok");
gtk_widget_remove_css_class(GTK_WIDGET(p->tb[o]), "wrong");
if (gtk_toggle_button_get_active(p->tb[o]))
active = o;
}
if (active == p->q->correct) {
mcq_ok++;
if (active >= 0)
gtk_widget_add_css_class(GTK_WIDGET(p->tb[active]), "ok");
} else if (active >= 0) {
gtk_widget_add_css_class(GTK_WIDGET(p->tb[active]), "wrong");
}
if (p->hint)
gtk_widget_set_visible(p->hint, TRUE);
}
for (guint i = 0; i < ctx->entries->len; i++) {
GtkWidget *entry = g_array_index(ctx->entries, GtkWidget *, i);
const char *txt = gtk_editable_get_text(GTK_EDITABLE(entry));
char *norm = normalize_answer(txt);
gboolean ok = answer_accepts(norm, ctx->lesson->gaps[i].answers);
gtk_widget_remove_css_class(entry, "answer-ok");
gtk_widget_remove_css_class(entry, "answer-wrong");
gtk_widget_add_css_class(entry, ok ? "answer-ok" : "answer-wrong");
if (ok)
gap_ok++;
g_free(norm);
if (i < ctx->gap_hints->len) {
GtkWidget *hint = g_array_index(ctx->gap_hints, GtkWidget *, i);
if (hint)
gtk_widget_set_visible(hint, TRUE);
}
}
if (ctx->essay && ctx->lesson->write_prompt) {
GtkTextBuffer *buf = gtk_text_view_get_buffer(GTK_TEXT_VIEW(ctx->essay));
GtkTextIter a, b;
char *raw;
char *norm;
gboolean keys = TRUE;
gtk_text_buffer_get_bounds(buf, &a, &b);
raw = gtk_text_buffer_get_text(buf, &a, &b, FALSE);
norm = normalize_answer(raw);
if (ctx->lesson->write_keys) {
gchar **parts = g_strsplit(ctx->lesson->write_keys, "|", -1);
for (int i = 0; parts[i]; i++) {
if (parts[i][0] && !en_has_word(norm, parts[i]))
keys = FALSE;
}
g_strfreev(parts);
}
write_ok = keys && en_word_count(norm) >= ctx->lesson->write_min;
g_free(norm);
g_free(raw);
}
all = mcq_ok == (int)ctx->picks->len
&& gap_ok == ctx->lesson->n_gaps
&& write_ok;
if (all) {
set_feedback(ctx->feedback, TRUE, tr("feedback_ok"));
if (ctx->model)
gtk_widget_set_visible(ctx->model, TRUE);
if (ctx->on_done)
ctx->on_done(ctx->year, ctx->index);
} else if (ctx->essay && !write_ok &&
mcq_ok == (int)ctx->picks->len &&
gap_ok == ctx->lesson->n_gaps) {
set_feedback(ctx->feedback, FALSE, tr("en_write_short"));
} else {
set_feedback(ctx->feedback, FALSE, tr("feedback_retry_short"));
}
}
GtkWidget *skill_ex_page(const EnLesson *lesson, int year, int index,
void (*on_done)(int, int),
const char *back, const char *title_key,
const char *voice) {
EnEx *ctx = g_new0(EnEx, 1);
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *body;
GtkWidget *check;
ctx->year = year;
ctx->index = index;
ctx->lesson = lesson;
ctx->on_done = on_done;
ctx->voice = voice && voice[0] ? voice : "en";
ctx->picks = g_array_new(FALSE, FALSE, sizeof(EnPick));
ctx->entries = g_array_new(FALSE, FALSE, sizeof(GtkWidget *));
ctx->gap_hints = g_array_new(FALSE, TRUE, sizeof(GtkWidget *));
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, title_key, NULL));
/* Keep the recording in view while the questions scroll. Lessons that
* also have a reading passage leave it in the scroll, above the player. */
if (lesson->listening && !lesson->reading)
en_add_listen(ctx, page);
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, 12);
gtk_widget_set_hexpand(body, TRUE);
gtk_widget_set_margin_bottom(body, 12);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), body);
if (lesson->reading) {
en_heading(body, lesson->n_read ? "en_sec_read" : "en_sec_read");
en_passage(body, lesson->reading, FALSE);
if (lesson->n_read)
en_add_mcq(ctx, body, lesson->readq, lesson->read_hints, lesson->n_read);
}
if (lesson->listening && lesson->reading)
en_add_listen(ctx, body);
if (lesson->n_listen)
en_add_mcq(ctx, body, lesson->listenq, lesson->listen_hints,
lesson->n_listen);
if (lesson->n_gaps) {
en_heading(body, "en_sec_gap");
for (int i = 0; i < lesson->n_gaps; i++) {
GtkWidget *card = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6);
GtkWidget *prompt = gtk_label_new(NULL);
GtkWidget *entry = gtk_entry_new();
GtkWidget *hint = NULL;
char *text = g_strdup_printf("%d.) %s", i + 1, lesson->gaps[i].prompt);
gtk_widget_add_css_class(card, "quiz-card");
gtk_label_set_text(GTK_LABEL(prompt), text);
g_free(text);
gtk_label_set_wrap(GTK_LABEL(prompt), TRUE);
gtk_label_set_xalign(GTK_LABEL(prompt), 0);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(card), prompt);
gtk_editable_set_width_chars(GTK_EDITABLE(entry), 24);
gtk_widget_set_halign(entry, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(card), entry);
if (lesson->gaps[i].meaning)
hint = meaning_add(card, lesson->gaps[i].meaning);
gtk_box_append(GTK_BOX(body), card);
g_array_append_val(ctx->entries, entry);
g_array_append_val(ctx->gap_hints, hint);
}
}
if (lesson->write_prompt) {
GtkWidget *prompt;
GtkWidget *view;
en_heading(body, "en_sec_write");
prompt = gtk_label_new(lesson->write_prompt);
gtk_label_set_wrap(GTK_LABEL(prompt), TRUE);
gtk_label_set_xalign(GTK_LABEL(prompt), 0);
gtk_widget_set_halign(prompt, GTK_ALIGN_START);
gtk_widget_add_css_class(prompt, "quiz-q");
gtk_box_append(GTK_BOX(body), prompt);
view = gtk_text_view_new();
gtk_text_view_set_wrap_mode(GTK_TEXT_VIEW(view), GTK_WRAP_WORD_CHAR);
gtk_widget_set_size_request(view, -1, 140);
gtk_widget_add_css_class(view, "quiz-card");
gtk_box_append(GTK_BOX(body), view);
ctx->essay = view;
ctx->model = gtk_label_new(lesson->write_model);
gtk_label_set_wrap(GTK_LABEL(ctx->model), TRUE);
gtk_label_set_selectable(GTK_LABEL(ctx->model), TRUE);
gtk_label_set_xalign(GTK_LABEL(ctx->model), 0);
gtk_widget_set_halign(ctx->model, GTK_ALIGN_START);
gtk_widget_add_css_class(ctx->model, "meaning");
gtk_widget_set_visible(ctx->model, FALSE);
gtk_box_append(GTK_BOX(body), ctx->model);
}
if (lesson->n_quiz) {
en_heading(body, "en_sec_quiz");
en_add_mcq(ctx, body, lesson->quiz, lesson->quiz_hints, lesson->n_quiz);
}
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_widget_set_margin_top(check, 8);
gtk_box_append(GTK_BOX(body), check);
g_signal_connect(check, "clicked", G_CALLBACK(en_check), ctx);
ctx->feedback = gtk_label_new("");
gtk_widget_set_halign(ctx->feedback, GTK_ALIGN_START);
gtk_label_set_wrap(GTK_LABEL(ctx->feedback), TRUE);
gtk_box_append(GTK_BOX(body), ctx->feedback);
return page;
}
static GtkWidget *build_en_unit_page(int year, int index) {
return skill_unit_page(&en_rt[year][index], &en_lessons[year][index],
en_map_page[year], en_title_key[year][index],
en_sub_key[year][index]);
}
static GtkWidget *build_en_ex_page(int year, int index) {
return skill_ex_page(&en_lessons[year][index], year, index, mark_en_done,
en_unit_page[year][index], en_title_key[year][index],
"en");
}
void add_en_pages(GtkStack *stack) {
en_prepare();
for (int y = 0; y < EN_YEARS; y++) {
gtk_stack_add_named(stack, build_enmap_page(y), en_map_page[y]);
for (int i = 0; i < EN_N; i++) {
gtk_stack_add_named(stack, build_en_unit_page(y, i),
en_unit_page[y][i]);
gtk_stack_add_named(stack, build_en_ex_page(y, i),
en_ex_page[y][i]);
}
}
}
void draw_uk_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 w = (double)width;
double h = (double)height;
(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_arc(cr, cx, cy, r, 0.0, 2.0 * G_PI);
cairo_clip(cr);
cairo_set_source_rgb(cr, 0.01, 0.14, 0.40);
cairo_paint(cr);
cairo_set_line_cap(cr, CAIRO_LINE_CAP_SQUARE);
cairo_set_source_rgb(cr, 1.0, 1.0, 1.0);
cairo_set_line_width(cr, w * 0.16);
cairo_move_to(cr, 0, 0);
cairo_line_to(cr, w, h);
cairo_move_to(cr, w, 0);
cairo_line_to(cr, 0, h);
cairo_stroke(cr);
cairo_set_source_rgb(cr, 0.80, 0.05, 0.15);
cairo_set_line_width(cr, w * 0.055);
cairo_move_to(cr, 0, 0);
cairo_line_to(cr, w, h);
cairo_move_to(cr, w, 0);
cairo_line_to(cr, 0, h);
cairo_stroke(cr);
cairo_set_source_rgb(cr, 1.0, 1.0, 1.0);
cairo_rectangle(cr, 0, h * 0.38, w, h * 0.24);
cairo_rectangle(cr, w * 0.38, 0, w * 0.24, h);
cairo_fill(cr);
cairo_set_source_rgb(cr, 0.80, 0.05, 0.15);
cairo_rectangle(cr, 0, h * 0.43, w, h * 0.14);
cairo_rectangle(cr, w * 0.43, 0, w * 0.14, h);
cairo_fill(cr);
cairo_restore(cr);
}