Keep the listening text on screen and tuck speed and volume into a short column.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
pepaskopec-blip 2026-10-09 18:11:35 +02:00
commit 6fbfed86f8
5 changed files with 284 additions and 228 deletions

View file

@ -522,6 +522,29 @@ popover > contents,
color: @fg_subtext;
font-size: 14px;
}
.listen-script {
color: @fg_text;
font-size: 15px;
}
.listen-caption {
color: @fg_subtext;
font-size: 12px;
font-weight: 600;
}
.listen-scale trough {
min-height: 4px;
border-radius: 999px;
background-color: alpha(@fg_text, 0.16);
}
.listen-scale highlight {
border-radius: 999px;
background-color: @accent;
}
.listen-scale value {
color: @fg_subtext;
font-size: 12px;
font-weight: 600;
}
.quiz-q {
color: @fg_text;
font-size: 15px;

View file

@ -1511,7 +1511,6 @@ struct EngExScreen: View {
@State private var gaps: [String]
@State private var essay = ""
@State private var played = false
@State private var showScript = false
@State private var revealed = false
@State private var showModel = false
@State private var fb = ("", "")
@ -1537,24 +1536,53 @@ struct EngExScreen: View {
var body: some View {
if let lesson = lessonOf(vm, year, id) {
let listening = lesson.strOrNull("listening")
let pinListen = listening != nil && lesson.strOrNull("reading") == nil
if pinListen, let listening {
scaffold(lesson) {
exerciseBody(lesson, inlineListening: nil)
}
.safeAreaInset(edge: .top, spacing: 0) {
listenBlock(listening)
.padding(.horizontal, pagePad)
.padding(.top, 4)
.padding(.bottom, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(.background)
}
} else {
scaffold(lesson) {
exerciseBody(lesson, inlineListening: listening)
}
}
}
}
private func scaffold<Content: View>(_ lesson: J, @ViewBuilder content: @escaping () -> Content) -> some View {
ExerciseScaffold(
vm: vm,
title: vm.tr(lesson.str("titleKey")),
sub: nil,
action: vm.tr("check"),
onAction: { check(lesson) },
feedback: fb.0,
kind: fb.1,
content: content
)
}
@ViewBuilder
private func exerciseBody(_ lesson: J, inlineListening: String?) -> some View {
let p = vm.palette
ExerciseScaffold(vm: vm, title: vm.tr(lesson.str("titleKey")), sub: nil, action: vm.tr("check"), onAction: {
check(lesson)
}, feedback: fb.0, kind: fb.1) {
if let reading = lesson.strOrNull("reading") {
section("en_sec_read")
Text(reading).textSelection(.enabled)
choiceBlock(lesson.arr("readQuiz"), picks: $readPicks)
}
if let listening = lesson.strOrNull("listening") {
section("en_sec_listen")
Text(vm.tr("en_listen_note"))
.font(.subheadline)
.foregroundStyle(.secondary)
listenControls(listening)
if showScript {
Text(listening).textSelection(.enabled)
}
if let inlineListening {
listenBlock(inlineListening)
choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
} else if lesson.strOrNull("listening") != nil {
choiceBlock(lesson.arr("listenQuiz"), picks: $listenPicks)
}
let gapRows = lesson.arr("gaps")
@ -1593,7 +1621,44 @@ struct EngExScreen: View {
choiceBlock(quiz, picks: $quizPicks)
}
}
private var pagePad: CGFloat {
#if os(macOS)
MacChrome.pageInset
#else
16
#endif
}
private func listenBlock(_ text: String) -> some View {
VStack(alignment: .leading, spacing: 8) {
section("en_sec_listen")
Text(vm.tr("en_listen_note"))
.font(.subheadline)
.foregroundStyle(.secondary)
GlassCard {
#if os(macOS)
HStack(alignment: .top, spacing: 22) {
listenControls(text)
listenScript(text)
}
#else
VStack(alignment: .leading, spacing: 14) {
listenScript(text)
listenControls(text)
}
#endif
}
}
}
private func listenScript(_ text: String) -> some View {
Text(text)
.font(.body)
.foregroundStyle(.primary)
.multilineTextAlignment(.leading)
.textSelection(.enabled)
.frame(maxWidth: .infinity, alignment: .leading)
}
private func section(_ key: String) -> some View {
@ -1608,12 +1673,14 @@ struct EngExScreen: View {
case .paused: vm.tr("en_resume")
case .idle: vm.tr("en_play")
}
return VStack(alignment: .leading, spacing: 8) {
PillButton(label: label) {
return VStack(alignment: .leading, spacing: 12) {
Button(label) {
played = true
speaker.onFinish = { showScript = true }
speaker.toggle(text)
}
.buttonStyle(.borderedProminent)
.controlSize(.large)
.frame(maxWidth: .infinity)
listenSlider(vm.tr("en_speed"), value: Double(speaker.rate), range: 0.6...1.5, caption: String(format: "%.1f×", speaker.rate)) { next in
speaker.rate = Float(next)
}
@ -1621,13 +1688,15 @@ struct EngExScreen: View {
speaker.volume = Float(next)
}
}
.frame(width: 228)
}
private func listenSlider(_ title: String, value: Double, range: ClosedRange<Double>, caption: String, set: @escaping (Double) -> Void) -> some View {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(title)
.frame(width: 88, alignment: .leading)
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
HStack(spacing: 8) {
Slider(
value: Binding(get: { value }, set: set),
in: range,
@ -1635,10 +1704,13 @@ struct EngExScreen: View {
if !editing { speaker.applySettings() }
}
)
.controlSize(.small)
Text(caption)
.font(.caption.weight(.semibold))
.monospacedDigit()
.frame(width: 52, alignment: .trailing)
.foregroundStyle(.secondary)
.frame(width: 46, alignment: .trailing)
}
}
}

View file

@ -2498,9 +2498,9 @@ def main() -> None:
' {"en_resume", "Pokračovat", "Resume"},',
' {"en_speed", "Rychlost", "Speed"},',
' {"en_volume", "Hlasitost", "Volume"},',
' {"en_listen_note", "Nejdřív si přečtěte otázky. Nahrávku přehraje hlas zařízení. Bez něj se věty ukážou postupně.", "Read the questions first. The device voice plays the recording. Without one, the sentences appear one by one."},',
' {"en_listen_note", "Text nahrávky zůstává vidět. Přehrání obstará hlas zařízení.", "The recording text stays visible. The device voice plays it."},',
' {"en_listen_first", "Nejdřív poslech přehrajte.", "Play the recording first."},',
' {"en_listen_fallback", "Hlas není dostupný, věty se ukazují postupně.", "No voice is available, so the sentences appear one by one."},',
' {"en_listen_fallback", "Hlas není dostupný. Text si můžete přečíst.", "No voice is available. You can read the text."},',
' {"en_write_short", "Text je krátký nebo v něm chybí požadovaná slova.", "The text is short or it misses the required words."},',
' {"en_model", "Vzor", "Model"},',
' {"en_write_ph", "Pište anglicky…", "Write in English…"},',

View file

@ -36,7 +36,6 @@ typedef struct {
GArray *entries;
GArray *gap_hints;
GtkWidget *essay;
GtkWidget *script;
GtkWidget *play_note;
GtkWidget *play_btn;
gboolean played;
@ -50,12 +49,8 @@ typedef struct {
double gain;
const char *voice;
GPid pid;
guint timer;
guint tick_ms;
guint apply_src;
guint poll;
int tick;
gchar **sentences;
#ifdef __APPLE__
id synth;
#endif
@ -863,30 +858,6 @@ static gboolean en_has_word(const char *norm, const char *word) {
return ok;
}
static void en_split_script(EnEx *ctx) {
GPtrArray *parts = g_ptr_array_new();
const char *text = ctx->lesson->listening;
const char *start = text;
const char *p = text;
while (p && *p) {
if ((*p == '.' || *p == '?' || *p == '!') && p[1] == ' ') {
g_ptr_array_add(parts, g_strndup(start, (gsize)(p - start + 1)));
p += 2;
start = p;
continue;
}
p++;
}
if (start && *start)
g_ptr_array_add(parts, g_strdup(start));
if (parts->len == 0)
g_ptr_array_add(parts, g_strdup(text ? text : ""));
g_ptr_array_add(parts, NULL);
ctx->sentences = (gchar **)g_ptr_array_free(parts, FALSE);
ctx->tick = 0;
}
static void en_sync_play(EnEx *ctx) {
const char *key = "en_play";
@ -899,78 +870,16 @@ static void en_sync_play(EnEx *ctx) {
gtk_button_set_label(GTK_BUTTON(ctx->play_btn), tr(key));
}
static void en_show_script(EnEx *ctx) {
if (ctx->script && ctx->lesson->listening) {
gtk_label_set_text(GTK_LABEL(ctx->script), ctx->lesson->listening);
gtk_widget_set_visible(ctx->script, TRUE);
}
}
static void en_schedule_tick(EnEx *ctx);
static gboolean en_tick(gpointer data) {
EnEx *ctx = data;
GString *shown;
int i;
if (ctx->paused)
return G_SOURCE_CONTINUE;
if (!ctx->sentences || !ctx->sentences[ctx->tick]) {
ctx->timer = 0;
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
shown = g_string_new(NULL);
for (i = 0; i <= ctx->tick && ctx->sentences[i]; i++) {
if (i)
g_string_append_c(shown, ' ');
g_string_append(shown, ctx->sentences[i]);
}
gtk_label_set_text(GTK_LABEL(ctx->script), shown->str);
gtk_widget_set_visible(ctx->script, TRUE);
g_string_free(shown, TRUE);
ctx->tick++;
if (!ctx->sentences[ctx->tick]) {
ctx->timer = 0;
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
return G_SOURCE_CONTINUE;
}
static void en_schedule_tick(EnEx *ctx) {
guint ms = ctx->tick_ms;
if (ctx->timer)
g_source_remove(ctx->timer);
if (ms < 400)
ms = 400;
ctx->timer = g_timeout_add(ms, en_tick, ctx);
}
static void en_start_fallback(EnEx *ctx) {
if (ctx->fallback)
return;
ctx->fallback = TRUE;
ctx->played = TRUE;
ctx->busy = 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);
}
if (!ctx->sentences)
en_split_script(ctx);
ctx->tick = 0;
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
en_sync_play(ctx);
en_tick(ctx);
}
static gboolean en_launch(EnEx *ctx);
@ -996,11 +905,8 @@ static void en_voice_done(GPid pid, gint status, gpointer data) {
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
if (!ok) {
if (!ok)
en_start_fallback(ctx);
return;
}
en_show_script(ctx);
}
static gboolean en_spawn(char **argv, GPid *pid) {
@ -1156,7 +1062,6 @@ static gboolean en_poll_speech(gpointer data) {
ctx->busy = FALSE;
ctx->paused = FALSE;
en_sync_play(ctx);
en_show_script(ctx);
return G_SOURCE_REMOVE;
}
#endif
@ -1242,10 +1147,7 @@ static void en_resume_voice(EnEx *ctx) {
#endif
if (ctx->voice_dirty) {
ctx->voice_dirty = FALSE;
if (ctx->fallback) {
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
} else if (ctx->pid) {
if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
kill((pid_t)ctx->pid, SIGKILL);
@ -1279,11 +1181,8 @@ static void en_restart_voice(EnEx *ctx) {
return;
}
#endif
if (ctx->fallback) {
ctx->tick_ms = (guint)(2200.0 / (ctx->speed > 0.2 ? ctx->speed : 0.9));
en_schedule_tick(ctx);
if (ctx->fallback)
return;
}
if (ctx->pid) {
ctx->restarting = TRUE;
#ifdef G_OS_UNIX
@ -1332,24 +1231,116 @@ static void en_play_clicked(GtkButton *button, gpointer data) {
en_pause_voice(ctx);
}
static GtkWidget *en_labeled_scale(EnEx *ctx, const char *key, const char *which,
double min, double max, double value, int digits) {
GtkWidget *row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 10);
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.05);
min, max, max > 10 ? 1 : 0.1);
i18n_bind(lab, key, 0);
gtk_widget_set_size_request(lab, 96, -1);
gtk_widget_add_css_class(lab, "listen-caption");
gtk_label_set_xalign(GTK_LABEL(lab), 0);
gtk_range_set_value(GTK_RANGE(scale), value);
gtk_scale_set_digits(GTK_SCALE(scale), digits);
gtk_widget_set_hexpand(scale, TRUE);
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(row), lab);
gtk_box_append(GTK_BOX(row), scale);
return row;
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) {
@ -1474,6 +1465,11 @@ GtkWidget *skill_ex_page(const EnLesson *lesson, int year, int index,
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);
@ -1493,46 +1489,11 @@ GtkWidget *skill_ex_page(const EnLesson *lesson, int year, int index,
en_add_mcq(ctx, body, lesson->readq, lesson->read_hints, lesson->n_read);
}
if (lesson->listening) {
GtkWidget *play;
GtkWidget *note;
en_heading(body, "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(body), note);
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_halign(play, GTK_ALIGN_START);
gtk_box_append(GTK_BOX(body), play);
ctx->play_btn = play;
g_signal_connect(play, "clicked", G_CALLBACK(en_play_clicked), ctx);
gtk_box_append(GTK_BOX(body),
en_labeled_scale(ctx, "en_speed", "speed",
0.6, 1.5, ctx->speed, 1));
gtk_box_append(GTK_BOX(body),
en_labeled_scale(ctx, "en_volume", "volume",
0, 100, ctx->gain * 100.0, 0));
ctx->play_note = gtk_label_new("");
gtk_label_set_wrap(GTK_LABEL(ctx->play_note), TRUE);
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(body), ctx->play_note);
ctx->script = en_passage(body, "", TRUE);
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");

View file

@ -14,9 +14,9 @@
{"en_resume", "Pokračovat", "Resume"},
{"en_speed", "Rychlost", "Speed"},
{"en_volume", "Hlasitost", "Volume"},
{"en_listen_note", "Nejdřív si přečtěte otázky. Nahrávku přehraje hlas zařízení. Bez něj se věty ukážou postupně.", "Read the questions first. The device voice plays the recording. Without one, the sentences appear one by one."},
{"en_listen_note", "Text nahrávky zůstává vidět. Přehrání obstará hlas zařízení.", "The recording text stays visible. The device voice plays it."},
{"en_listen_first", "Nejdřív poslech přehrajte.", "Play the recording first."},
{"en_listen_fallback", "Hlas není dostupný, věty se ukazují postupně.", "No voice is available, so the sentences appear one by one."},
{"en_listen_fallback", "Hlas není dostupný. Text si můžete přečíst.", "No voice is available. You can read the text."},
{"en_write_short", "Text je krátký nebo v něm chybí požadovaná slova.", "The text is short or it misses the required words."},
{"en_model", "Vzor", "Model"},
{"en_write_ph", "Pište anglicky…", "Write in English…"},