Move the GTK application sources into desktop/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
FBukovina 2026-09-17 15:44:16 +02:00
commit 4708c69d9c
46 changed files with 0 additions and 0 deletions

88
desktop/CMakeLists.txt Normal file
View file

@ -0,0 +1,88 @@
cmake_minimum_required(VERSION 3.16)
project(maturita C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
# GTK4 is found through pkg-config everywhere: Linux/macOS packages, MSYS2,
# vcpkg and the native MSVC (gvsbuild) package all ship .pc files.
if(MSVC)
# On MSVC the Unix-style flags pkg-config prints are not understood, so
# ask for the native "/I" and ".lib" form instead.
find_program(PKG_CONFIG_EXECUTABLE
NAMES pkg-config pkg-config.exe pkgconf pkgconf.exe)
if(NOT PKG_CONFIG_EXECUTABLE)
message(FATAL_ERROR
"pkg-config was not found. A native GTK4 install for MSVC "
"(e.g. gvsbuild) provides it; set PKG_CONFIG_PATH to "
"<GTK>/lib/pkgconfig. See README.md.")
endif()
execute_process(
COMMAND "${PKG_CONFIG_EXECUTABLE}" --cflags gtk4 --msvc-syntax
OUTPUT_VARIABLE GTK4_CFLAGS_RAW
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE GTK4_CFLAGS_RESULT)
execute_process(
COMMAND "${PKG_CONFIG_EXECUTABLE}" --libs gtk4 --msvc-syntax
OUTPUT_VARIABLE GTK4_LIBS_RAW
OUTPUT_STRIP_TRAILING_WHITESPACE
RESULT_VARIABLE GTK4_LIBS_RESULT)
if(NOT GTK4_CFLAGS_RESULT EQUAL 0 OR NOT GTK4_LIBS_RESULT EQUAL 0)
message(FATAL_ERROR
"pkg-config could not find GTK4. Set PKG_CONFIG_PATH to "
"<GTK>/lib/pkgconfig (for example C:\\gtk\\lib\\pkgconfig).")
endif()
separate_arguments(GTK4_CFLAGS WINDOWS_COMMAND "${GTK4_CFLAGS_RAW}")
separate_arguments(GTK4_LIBS WINDOWS_COMMAND "${GTK4_LIBS_RAW}")
else()
find_package(PkgConfig REQUIRED)
pkg_check_modules(GTK4 REQUIRED IMPORTED_TARGET gtk4)
endif()
# WIN32 suppresses the console window on Windows; the app still uses main(),
# so MSVC needs mainCRTStartup as the entry point.
file(GLOB MATURITA_SOURCES CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.c")
if(WIN32)
add_executable(maturita WIN32 ${MATURITA_SOURCES}
"${CMAKE_CURRENT_SOURCE_DIR}/maturita.rc")
else()
add_executable(maturita ${MATURITA_SOURCES})
endif()
if(MSVC)
target_compile_options(maturita PRIVATE ${GTK4_CFLAGS})
target_link_options(maturita PRIVATE ${GTK4_LIBS})
target_link_options(maturita PRIVATE /ENTRY:mainCRTStartup)
target_compile_definitions(maturita PRIVATE _CRT_SECURE_NO_WARNINGS)
else()
target_link_libraries(maturita PRIVATE PkgConfig::GTK4 m)
target_compile_options(maturita PRIVATE
-Wall -Wextra -Wno-deprecated-declarations)
if(APPLE)
target_link_libraries(maturita PRIVATE
"-framework AppKit" "-framework Foundation")
endif()
endif()
# The GTK stylesheet, window icon and Freedesktop share/ tree are loaded at
# runtime from the working directory (or next to the binary).
add_custom_command(TARGET maturita POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/style.css
$<TARGET_FILE_DIR:maturita>/style.css
COMMAND ${CMAKE_COMMAND} -E make_directory
$<TARGET_FILE_DIR:maturita>/icons/hicolor/512x512/apps
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/assets/app-icon.png
$<TARGET_FILE_DIR:maturita>/icons/hicolor/512x512/apps/maturita.png
COMMAND ${CMAKE_COMMAND} -E make_directory
${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/hicolor/512x512/apps
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_CURRENT_SOURCE_DIR}/assets/app-icon.png
${CMAKE_CURRENT_SOURCE_DIR}/assets/icons/hicolor/512x512/apps/maturita.png
COMMAND ${CMAKE_COMMAND} -E copy_directory
${CMAKE_CURRENT_SOURCE_DIR}/share
$<TARGET_FILE_DIR:maturita>/share)

101
desktop/Makefile Normal file
View file

@ -0,0 +1,101 @@
CC = gcc
# Make sure pkg-config is found even when make runs with a minimal PATH
# (e.g. launched from an IDE such as CLion on macOS).
PKG_CONFIG ?= $(firstword $(wildcard \
$(addsuffix /pkg-config,$(subst :, ,$(PATH)) /opt/homebrew/bin /usr/local/bin)))
ifeq ($(PKG_CONFIG),)
PKG_CONFIG := pkg-config
endif
# Use $(shell ...) instead of backticks so the flags are expanded by make
# itself; IDEs that import the Makefile via a dry run then see the real
# include paths.
GTK_CFLAGS := $(shell $(PKG_CONFIG) --cflags gtk4)
GTK_LIBS := $(shell $(PKG_CONFIG) --libs gtk4)
CFLAGS = -Wall -Wextra -Wno-deprecated-declarations $(GTK_CFLAGS)
LIBS = $(GTK_LIBS) -lm
# Windows (MSYS2 / MinGW): .exe suffix, no console window, embed .ico.
ifeq ($(OS),Windows_NT)
TARGET = maturita.exe
LIBS += -mwindows
WINDRES ?= windres
RC_OBJ = maturita_rc.o
UNAME_S :=
else
TARGET = maturita
RC_OBJ =
UNAME_S := $(shell uname -s 2>/dev/null)
# macOS Dock icon is set via AppKit (see macos_dock.c).
ifeq ($(UNAME_S),Darwin)
LIBS += -framework AppKit -framework Foundation
endif
endif
SRC = $(wildcard *.c)
OBJ = $(SRC:.c=.o)
ICON_SRC = assets/app-icon.png
# Always use the real 512×512 asset — copying app-icon.png (1024×1024) breaks
# linuxdeploy, which rejects non-standard icon resolutions.
ICON_DST = share/icons/hicolor/512x512/apps/maturita.png
ICON_RUNTIME = assets/icons/hicolor/512x512/apps/maturita.png
ICON_ICO = assets/app-icon.ico
all: $(TARGET)
$(ICON_RUNTIME): $(ICON_DST)
mkdir -p $(dir $@)
cp "$(ICON_DST)" "$@"
ifeq ($(OS),Windows_NT)
$(RC_OBJ): maturita.rc $(ICON_ICO)
$(WINDRES) maturita.rc -o $(RC_OBJ)
$(TARGET): $(SRC) $(ICON_RUNTIME) $(RC_OBJ)
$(CC) $(CFLAGS) -o $(TARGET) $(SRC) $(RC_OBJ) $(LIBS)
else
$(TARGET): $(SRC) $(ICON_RUNTIME)
$(CC) $(CFLAGS) -o $(TARGET) $(SRC) $(LIBS)
endif
clean:
rm -f $(TARGET) maturita.exe $(OBJ) $(RC_OBJ) maturita_rc.o
run: $(TARGET)
./$(TARGET)
# Self-contained package for the current platform into dist/.
ifeq ($(OS),Windows_NT)
bundle: $(TARGET)
@rm -rf dist
@mkdir -p dist
@cp $(TARGET) dist/
@cp style.css dist/
@mkdir -p dist/icons/hicolor/512x512/apps
@cp "$(ICON_DST)" dist/icons/hicolor/512x512/apps/
@cp -R share dist/
@ldd $(TARGET) | grep -Ei '/(ucrt64|mingw64)/bin/' | awk '{print $$3}' \
| xargs -r -I{} cp -f {} dist/
@# GTK runtime data (schemas / pixbuf loaders) next to the exe.
@GTK_PREFIX="$$($(PKG_CONFIG) --variable=prefix gtk4)"; \
if [ -d "$$GTK_PREFIX/share/glib-2.0" ]; then \
mkdir -p dist/share && cp -R "$$GTK_PREFIX/share/glib-2.0" dist/share/; \
fi; \
if [ -d "$$GTK_PREFIX/lib/gdk-pixbuf-2.0" ]; then \
mkdir -p dist/lib && cp -R "$$GTK_PREFIX/lib/gdk-pixbuf-2.0" dist/lib/; \
fi
@echo "Bundled into dist/ - run dist/$(TARGET)"
else ifeq ($(UNAME_S),Darwin)
bundle:
@chmod +x scripts/bundle-macos.sh
@./scripts/bundle-macos.sh
else
bundle:
@chmod +x scripts/bundle-linux.sh
@./scripts/bundle-linux.sh
endif
.PHONY: all clean run bundle

BIN
desktop/assets/app-icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

BIN
desktop/assets/app-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

100
desktop/build-windows.bat Normal file
View file

@ -0,0 +1,100 @@
@echo off
setlocal EnableExtensions
REM ---------------------------------------------------------------------------
REM Native Windows build with MSVC. No MSYS2 or MinGW shell required.
REM
REM Requirements:
REM * Visual Studio 2019/2022 with the "Desktop development with C++"
REM workload installed.
REM * A GTK4 build for MSVC, for example the gvsbuild prebuilt package
REM unzipped to C:\gtk (see README.md).
REM
REM If GTK lives somewhere other than C:\gtk, set GTK_ROOT first, e.g.
REM set GTK_ROOT=D:\gtk
REM ---------------------------------------------------------------------------
if not defined GTK_ROOT set "GTK_ROOT=C:\gtk"
REM --- 1. Locate Visual Studio and enter the 64-bit MSVC environment --------
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if not exist "%VSWHERE%" (
echo [error] vswhere.exe not found.
echo Install Visual Studio with the "Desktop development with C++"
echo workload, then run this script again.
exit /b 1
)
set "VSPATH="
for /f "usebackq tokens=*" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VSPATH=%%i"
if not defined VSPATH (
echo [error] No Visual Studio C++ installation found.
exit /b 1
)
call "%VSPATH%\VC\Auxiliary\Build\vcvars64.bat" >nul
if errorlevel 1 (
echo [error] Failed to initialise the MSVC environment.
exit /b 1
)
REM --- 2. Point the build at the GTK4 installation --------------------------
set "PATH=%GTK_ROOT%\bin;%PATH%"
set "PKG_CONFIG_PATH=%GTK_ROOT%\lib\pkgconfig;%PKG_CONFIG_PATH%"
set "PKG_CONFIG=pkg-config"
where pkg-config >nul 2>nul
if errorlevel 1 (
where pkgconf >nul 2>nul
if errorlevel 1 (
echo [error] pkg-config/pkgconf was not found.
echo Make sure GTK_ROOT points at a GTK4 install that contains
echo bin\pkg-config.exe. Current GTK_ROOT: %GTK_ROOT%
exit /b 1
)
set "PKG_CONFIG=pkgconf"
)
REM --- 3. Collect the GTK4 flags in MSVC syntax -----------------------------
set "GTK_CFLAGS="
set "GTK_LIBS="
for /f "usebackq delims=" %%c in (`%PKG_CONFIG% --cflags gtk4 --msvc-syntax`) do set "GTK_CFLAGS=%%c"
for /f "usebackq delims=" %%l in (`%PKG_CONFIG% --libs gtk4 --msvc-syntax`) do set "GTK_LIBS=%%l"
if not defined GTK_CFLAGS (
echo [error] pkg-config could not find gtk4.
echo Check that "%PKG_CONFIG_PATH%" contains gtk4.pc.
exit /b 1
)
if not exist "assets\app-icon.ico" (
echo [error] assets\app-icon.ico is missing.
echo Generate it with: python scripts\gen-icons.py
exit /b 1
)
REM --- 4. Compile resources + sources ---------------------------------------
echo Building maturita.exe with MSVC...
rc /nologo /fo maturita.res maturita.rc
if errorlevel 1 (
echo [error] Resource compile failed.
exit /b 1
)
cl /nologo /O2 /MD /W3 /std:c11 /D_CRT_SECURE_NO_WARNINGS ^
%GTK_CFLAGS% *.c maturita.res ^
/Fe:maturita.exe ^
/link /SUBSYSTEM:WINDOWS /ENTRY:mainCRTStartup %GTK_LIBS%
if errorlevel 1 (
echo [error] Build failed.
exit /b 1
)
if not exist icons\hicolor\512x512\apps mkdir icons\hicolor\512x512\apps
copy /Y assets\app-icon.png icons\hicolor\512x512\apps\maturita.png >nul
echo.
echo Built maturita.exe ^(icon embedded from assets\app-icon.ico^)
echo Run it from this terminal with: maturita.exe
echo Or use run-windows.bat / double-click maturita.exe.

1601
desktop/cetba.c Normal file

File diff suppressed because it is too large Load diff

162
desktop/czech.c Normal file
View file

@ -0,0 +1,162 @@
#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) {
GtkWidget *btn;
GtkWidget *label;
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_size_request(btn, (int)CZ_BUBBLE, (int)CZ_BUBBLE);
label = gtk_label_new(cz_initials[index]);
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++) {
GtkWidget *btn = cz_make_node(i);
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");
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;
}

833
desktop/exercise_common.c Normal file
View file

@ -0,0 +1,833 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Exercise page shell */
/* ------------------------------------------------------------------ */
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);
*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
desktop/exercise_u1.c Normal file

File diff suppressed because it is too large Load diff

1540
desktop/exercise_u2.c Normal file

File diff suppressed because it is too large Load diff

1547
desktop/exercise_u3.c Normal file

File diff suppressed because it is too large Load diff

83
desktop/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);

911
desktop/hw.c Normal file
View file

@ -0,0 +1,911 @@
#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 mauve = color_from_hex(app_theme.accent);
const Rgb rail = color_from_hex(app_theme.rail);
const Rgb muted = color_from_hex(app_theme.subtext);
double hx, hy;
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);
hw_point(0.0, &hx, &hy);
draw_node_halo(cr, hx, hy, NODE_SIZE * 1.05,
mauve.r, mauve.g, mauve.b, 0.18);
cairo_new_path(cr);
hw_path(cr, 0.0, t_end);
cairo_set_line_width(cr, 16);
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_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);
}

1488
desktop/i18n.c Normal file

File diff suppressed because it is too large Load diff

467
desktop/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
desktop/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

494
desktop/main.c Normal file
View file

@ -0,0 +1,494 @@
#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);
}
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);
gtk_window_set_child(window, GTK_WIDGET(main_stack));
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);
}
}
gtk_stack_set_visible_child_name(main_stack, "welcome");
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);
gtk_window_present(window);
}
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;
}

1220
desktop/maturita.h Normal file

File diff suppressed because it is too large Load diff

3
desktop/maturita.rc Normal file
View file

@ -0,0 +1,3 @@
/* Windows application icon embedded into maturita.exe.
* The first ICON resource becomes the Explorer / taskbar icon. */
1 ICON "assets/app-icon.ico"

783
desktop/mluvnice.c Normal file
View file

@ -0,0 +1,783 @@
#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 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);
}

132
desktop/navigation.c Normal file
View file

@ -0,0 +1,132 @@
#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 *heading;
GtkWidget *card;
GtkWidget *button;
box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 18);
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);
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);
card = gtk_label_new(NULL);
i18n_bind(card, "welcome_body", 0);
gtk_label_set_justify(GTK_LABEL(card), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(card), TRUE);
gtk_label_set_max_width_chars(GTK_LABEL(card), 48);
gtk_widget_add_css_class(card, "card");
gtk_box_append(GTK_BOX(box), card);
button = gtk_button_new();
i18n_bind(button, "continue", 1);
gtk_widget_set_halign(button, GTK_ALIGN_CENTER);
gtk_widget_set_margin_top(button, 6);
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;
}

3987
desktop/net.c Normal file

File diff suppressed because it is too large Load diff

321
desktop/progress.c Normal file
View file

@ -0,0 +1,321 @@
#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);
}
}
void refresh_completion_ui(void) {
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 marker */
if (u->node) {
if (all && u->n_ex > 0) {
gtk_widget_remove_css_class(u->node, "current");
gtk_widget_add_css_class(u->node, "done");
if (u->node_done_icon)
gtk_widget_set_visible(u->node_done_icon, TRUE);
} else {
gtk_widget_remove_css_class(u->node, "done");
gtk_widget_add_css_class(u->node, "current");
if (u->node_done_icon)
gtk_widget_set_visible(u->node_done_icon, FALSE);
}
}
}
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++;
}
}
}

446
desktop/roadmap.c Normal file
View file

@ -0,0 +1,446 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Roadmap page */
/* ------------------------------------------------------------------ */
void draw_node_halo(cairo_t *cr, double cx, double cy,
double radius, double r, double g, double b,
double alpha) {
cairo_pattern_t *grad;
grad = cairo_pattern_create_radial(cx, cy, 6.0, cx, cy, radius);
cairo_pattern_add_color_stop_rgba(grad, 0.0, r, g, b, alpha);
cairo_pattern_add_color_stop_rgba(grad, 0.55, r, g, b, alpha * 0.30);
cairo_pattern_add_color_stop_rgba(grad, 1.0, r, g, b, 0.0);
cairo_set_source(cr, grad);
cairo_paint(cr);
cairo_pattern_destroy(grad);
}
/* Draw the checkered "finish line" node: checkerboard behind the unit
* number and the same lock glyph the locked units use. */
void draw_finish_cell(GtkDrawingArea *area, cairo_t *cr,
int width, int height, gpointer data) {
const double s = 11.0;
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;
double start_x = cx - r;
double start_y = cy - r;
int cols = (int)ceil(2.0 * r / s);
int rows = (int)ceil(2.0 * r / s);
char buf[8];
PangoLayout *layout;
PangoFontDescription *fd;
PangoRectangle ink;
double num_cy;
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, start_x, start_y, 2.0 * r, 2.0 * r);
cairo_set_source_rgb(cr, fin_dark.r, fin_dark.g, fin_dark.b);
cairo_fill(cr);
for (iy = 0; iy < rows; iy++) {
for (ix = 0; ix < cols; ix++) {
if (((ix + iy) & 1) == 0)
continue;
cairo_rectangle(cr, start_x + ix * s, start_y + iy * s, s, s);
cairo_set_source_rgb(cr, fin_light.r, fin_light.g, fin_light.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);
/* lock glyph below the number, as on the other locked units */
lock_paint(cr, cx - 8.0, num_cy + ink.height / 2.0 + 6.0, 16.0, &gray);
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 double x0 = ROAD_MX;
const double x1 = (double)road_cw - ROAD_MX;
const Rgb mauve = color_from_hex(app_theme.accent);
const Rgb blue = color_from_hex(app_theme.accent3);
const Rgb rail = color_from_hex(app_theme.rail);
const Rgb muted = color_from_hex(app_theme.subtext);
double hx, hy;
(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);
road_point((double)(NUM_UNLOCKED - 1), &hx, &hy);
draw_node_halo(cr, hx, hy, NODE_SIZE * 1.05,
mauve.r, mauve.g, mauve.b, 0.18);
cairo_new_path(cr);
road_path(cr, 0.0, t_end);
cairo_set_line_width(cr, 16);
cairo_set_source_rgb(cr, rail.r, rail.g, rail.b);
cairo_stroke(cr);
if (t_lit > 0.0) {
cairo_pattern_t *grad;
cairo_new_path(cr);
road_path(cr, 0.0, t_lit);
grad = cairo_pattern_create_linear(x0, 0.0, x1, 0.0);
cairo_pattern_add_color_stop_rgba(grad, 0.0,
mauve.r, mauve.g, mauve.b, 0.16);
cairo_pattern_add_color_stop_rgba(grad, 1.0,
blue.r, blue.g, blue.b, 0.16);
cairo_set_source(cr, grad);
cairo_set_line_width(cr, 34);
cairo_stroke_preserve(cr);
cairo_pattern_destroy(grad);
grad = cairo_pattern_create_linear(x0, 0.0, x1, 0.0);
cairo_pattern_add_color_stop_rgba(grad, 0.0,
mauve.r, mauve.g, mauve.b, 1.0);
cairo_pattern_add_color_stop_rgba(grad, 1.0,
blue.r, blue.g, blue.b, 1.0);
cairo_set_source(cr, grad);
cairo_set_line_width(cr, 16);
cairo_stroke_preserve(cr);
cairo_pattern_destroy(grad);
}
{
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;
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);
} else {
icon = icon_area_new(draw_check_icon,
0.1176, 0.1176, 0.1804, 18);
gtk_widget_set_visible(icon, FALSE);
u->node = card;
u->node_done_icon = icon;
g_object_set_data_full(G_OBJECT(card), "target",
g_strdup(u->page), g_free);
g_signal_connect(card, "clicked", G_CALLBACK(on_nav_clicked), NULL);
}
gtk_box_append(GTK_BOX(vbox), icon);
}
road_nodes[index] = card;
gtk_fixed_put(fixed, card, 0, 0);
name = gtk_label_new(u->title);
gtk_widget_set_size_request(name, (int)(PATH_SPAC - 20.0), -1);
gtk_widget_set_halign(name, GTK_ALIGN_CENTER);
gtk_label_set_justify(GTK_LABEL(name), GTK_JUSTIFY_CENTER);
gtk_label_set_wrap(GTK_LABEL(name), TRUE);
gtk_widget_add_css_class(name, "unit-name");
if (locked)
gtk_widget_add_css_class(name, "unit-name-locked");
road_labels[index] = name;
gtk_fixed_put(fixed, name, 0, 0);
}
GtkWidget *build_roadmap_page(void) {
GtkWidget *page;
GtkWidget *scroll;
GtkWidget *wrap;
GtkWidget *fixed;
GtkWidget *rail;
GtkAdjustment *ha;
GtkAdjustment *va;
int i;
roadmap_geometry(1000.0);
page = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0);
gtk_widget_set_hexpand(page, TRUE);
gtk_widget_set_vexpand(page, TRUE);
gtk_widget_set_margin_start(page, 32);
gtk_widget_set_margin_end(page, 32);
gtk_widget_set_margin_top(page, 24);
gtk_widget_set_margin_bottom(page, 24);
gtk_box_append(GTK_BOX(page),
top_bar("subjects", "roadmap_title", "roadmap_sub"));
scroll = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroll),
GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC);
gtk_widget_set_vexpand(scroll, TRUE);
gtk_widget_set_margin_top(scroll, 14);
gtk_box_append(GTK_BOX(page), scroll);
road_scroll = scroll;
wrap = gtk_center_box_new();
gtk_widget_set_vexpand(wrap, TRUE);
gtk_widget_set_hexpand(wrap, TRUE);
gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroll), wrap);
fixed = gtk_fixed_new();
gtk_widget_set_halign(fixed, GTK_ALIGN_CENTER);
gtk_widget_set_valign(fixed, GTK_ALIGN_CENTER);
gtk_widget_set_size_request(fixed, road_cw, road_ch);
gtk_center_box_set_center_widget(GTK_CENTER_BOX(wrap), fixed);
road_fixed = fixed;
rail = gtk_drawing_area_new();
gtk_widget_set_size_request(rail, road_cw, road_ch);
gtk_drawing_area_set_draw_func(GTK_DRAWING_AREA(rail), draw_rail, NULL, NULL);
gtk_fixed_put(GTK_FIXED(fixed), rail, 0, 0);
road_rail = rail;
for (i = 0; i < NUM_UNITS; i++)
add_path_node(GTK_FIXED(fixed), i);
ha = gtk_scrolled_window_get_hadjustment(GTK_SCROLLED_WINDOW(scroll));
va = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(scroll));
g_signal_connect(ha, "notify::page-size",
G_CALLBACK(road_adjust_notify), NULL);
g_signal_connect(va, "notify::page-size",
G_CALLBACK(road_adjust_notify), NULL);
road_relayout_later();
return page;
}

17
desktop/run-windows.bat Normal file
View file

@ -0,0 +1,17 @@
@echo off
setlocal
REM Launch a locally built maturita.exe with the GTK4 runtime on PATH, so the
REM app can be started by double-clicking even when GTK is not installed
REM system-wide.
if not defined GTK_ROOT set "GTK_ROOT=C:\gtk"
set "PATH=%GTK_ROOT%\bin;%PATH%"
if not exist "%~dp0maturita.exe" (
echo [error] maturita.exe not found next to this script.
echo Build it first with build-windows.bat.
exit /b 1
)
start "" /D "%~dp0" "%~dp0maturita.exe"

123
desktop/scripts/bundle-linux.sh Executable file
View file

@ -0,0 +1,123 @@
#!/usr/bin/env bash
# Build a self-contained Linux AppImage (x86_64).
# Requires: make, pkg-config, GTK4, curl, and fuse/appimagetool (pulled as needed).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) ARCH=x86_64 ;;
aarch64|arm64) ARCH=aarch64 ;;
*)
echo "Unsupported architecture: $ARCH" >&2
exit 1
;;
esac
OUT_NAME="maturita-linux-${ARCH}.AppImage"
APPDIR="$ROOT/dist/AppDir"
TOOLS="$ROOT/dist/tools"
# Real 512×512 PNG (assets/app-icon.png is 1024×1024 and linuxdeploy rejects it).
ICON_512="$ROOT/share/icons/hicolor/512x512/apps/maturita.png"
echo "==> Building maturita"
make -C "$ROOT" clean
make -C "$ROOT"
echo "==> Preparing AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin" \
"$APPDIR/usr/share/applications" \
"$APPDIR/usr/share/icons/hicolor/512x512/apps" \
"$APPDIR/icons/hicolor/512x512/apps" \
"$APPDIR/share"
cp -f "$ROOT/maturita" "$APPDIR/usr/bin/maturita"
cp -f "$ROOT/style.css" "$APPDIR/style.css"
cp -f "$ICON_512" "$APPDIR/icons/hicolor/512x512/apps/maturita.png"
cp -f "$ICON_512" "$APPDIR/usr/share/icons/hicolor/512x512/apps/maturita.png"
cp -f "$ICON_512" "$APPDIR/maturita.png"
cp -R "$ROOT/share/." "$APPDIR/share/"
cp -R "$ROOT/share/." "$APPDIR/usr/share/"
cp -f "$ROOT/share/applications/org.maturita.Maturita.desktop" \
"$APPDIR/usr/share/applications/org.maturita.Maturita.desktop"
# AppImage desktop entry must live at AppDir root and Exec= must be AppRun-compatible.
cp -f "$ROOT/share/applications/org.maturita.Maturita.desktop" \
"$APPDIR/org.maturita.Maturita.desktop"
# linuxdeploy expects Icon= without path and a matching PNG at AppDir root.
sed -i.bak 's|^Exec=.*|Exec=maturita|' "$APPDIR/org.maturita.Maturita.desktop"
sed -i.bak 's|^Icon=.*|Icon=maturita|' "$APPDIR/org.maturita.Maturita.desktop"
rm -f "$APPDIR/org.maturita.Maturita.desktop.bak"
mkdir -p "$TOOLS"
LINUXDEPLOY="$TOOLS/linuxdeploy-${ARCH}.AppImage"
GTK_PLUGIN="$TOOLS/linuxdeploy-plugin-gtk.sh"
if [[ ! -x "$LINUXDEPLOY" ]]; then
echo "==> Downloading linuxdeploy"
curl -fsSL -o "$LINUXDEPLOY" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-${ARCH}.AppImage"
chmod +x "$LINUXDEPLOY"
fi
if [[ ! -f "$GTK_PLUGIN" ]]; then
echo "==> Downloading linuxdeploy-plugin-gtk"
curl -fsSL -o "$GTK_PLUGIN" \
"https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh"
chmod +x "$GTK_PLUGIN"
fi
# Extract linuxdeploy if FUSE is unavailable (common on CI).
run_linuxdeploy() {
if "$LINUXDEPLOY" --appimage-help >/dev/null 2>&1; then
"$LINUXDEPLOY" "$@"
else
local extract_dir="$TOOLS/linuxdeploy-extracted"
rm -rf "$extract_dir"
cd "$TOOLS"
"$LINUXDEPLOY" --appimage-extract >/dev/null
mv squashfs-root "$extract_dir"
cd "$ROOT"
"$extract_dir/AppRun" "$@"
fi
}
echo "==> Bundling GTK libraries into AppDir"
export LDAI_OUTPUT="$ROOT/dist/$OUT_NAME"
export LINUXDEPLOY_OUTPUT_VERSION="${VERSION:-continuous}"
# Place plugin on PATH so linuxdeploy can find it.
export PATH="$TOOLS:$PATH"
run_linuxdeploy \
--appdir "$APPDIR" \
--executable "$APPDIR/usr/bin/maturita" \
--desktop-file "$APPDIR/org.maturita.Maturita.desktop" \
--icon-file "$APPDIR/maturita.png" \
--plugin gtk \
--output appimage
# linuxdeploy may write the AppImage into the cwd under another name;
# only rename when it is not already at the expected path (mv same→same
# fails under `set -e` and was aborting an otherwise successful build).
DEST="$ROOT/dist/$OUT_NAME"
if [[ ! -f "$DEST" ]]; then
shopt -s nullglob
for f in "$ROOT"/*.AppImage "$ROOT/dist"/*.AppImage; do
case "$(basename "$f")" in
linuxdeploy*|appimagetool*) continue ;;
esac
mv -f "$f" "$DEST"
break
done
shopt -u nullglob
fi
if [[ ! -f "$DEST" ]]; then
echo "AppImage was not created" >&2
find "$ROOT" "$ROOT/dist" -maxdepth 2 -name '*.AppImage' -print >&2 || true
exit 1
fi
chmod +x "$DEST"
echo "Created dist/$OUT_NAME"

280
desktop/scripts/bundle-macos.sh Executable file
View file

@ -0,0 +1,280 @@
#!/usr/bin/env bash
# Build a self-contained macOS .app with GTK dylibs relocated into the bundle.
# Requires: make, pkg-config, GTK4 (Homebrew).
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$ROOT"
ARCH="$(uname -m)"
case "$ARCH" in
arm64|aarch64) ARCH_TAG=arm64 ;;
x86_64) ARCH_TAG=x86_64 ;;
*)
echo "Unsupported architecture: $ARCH" >&2
exit 1
;;
esac
APP_NAME="Maturita.app"
APP="$ROOT/dist/$APP_NAME"
MACOS="$APP/Contents/MacOS"
RES="$APP/Contents/Resources"
FW="$APP/Contents/Frameworks"
ICON_512="$ROOT/share/icons/hicolor/512x512/apps/maturita.png"
OUT_ZIP="maturita-macos-${ARCH_TAG}.zip"
BREW_PREFIX="$(brew --prefix 2>/dev/null || echo /opt/homebrew)"
echo "==> Building maturita"
make -C "$ROOT" clean
make -C "$ROOT"
echo "==> Creating $APP_NAME"
rm -rf "$APP"
mkdir -p "$MACOS" "$RES/icons/hicolor/512x512/apps" "$RES/share" "$FW"
cp -f "$ROOT/maturita" "$MACOS/maturita-bin"
cp -f "$ROOT/style.css" "$RES/style.css"
cp -f "$ICON_512" "$RES/icons/hicolor/512x512/apps/maturita.png"
cp -R "$ROOT/share/." "$RES/share/"
# Resolve an absolute Homebrew-style dependency to a real file on disk.
resolve_lib() {
local dep="$1"
local candidate
if [[ -f "$dep" ]]; then
realpath "$dep"
return 0
fi
# Try common Homebrew layouts for the basename.
local base
base="$(basename "$dep")"
for candidate in \
"$BREW_PREFIX/lib/$base" \
"$BREW_PREFIX/opt"/*/lib/"$base" \
"$BREW_PREFIX/Cellar"/*/*/lib/"$base"; do
if [[ -f "$candidate" ]]; then
realpath "$candidate"
return 0
fi
done
return 1
}
# Recursively copy non-system dylibs into Frameworks and rewrite load paths.
# Unlike dylibbundler, this never prompts interactively and fails loudly.
bundle_deps() {
local target="$1"
local line dep name dest real
while IFS= read -r line; do
dep="$(echo "$line" | awk '{print $1}')"
case "$dep" in
/System/*|/usr/lib/*|@executable_path/*|@loader_path/*)
continue
;;
@rpath/*)
name="$(basename "$dep")"
if ! real="$(resolve_lib "$BREW_PREFIX/lib/$name")"; then
echo "error: cannot resolve @rpath dependency '$dep' needed by $target" >&2
exit 1
fi
;;
/*)
name="$(basename "$dep")"
case "$name" in
libjpeg*.dylib) name="libjpeg.8.dylib" ;;
esac
if [[ -f "$dep" ]]; then
real="$(realpath "$dep")"
elif ! real="$(resolve_lib "$dep")"; then
echo "error: cannot resolve dependency '$dep' needed by $target" >&2
exit 1
fi
;;
*)
continue
;;
esac
dest="$FW/$name"
if [[ ! -f "$dest" ]]; then
echo " + $name (from $real)"
cp -f "$real" "$dest"
chmod u+w "$dest"
install_name_tool -id "@executable_path/../Frameworks/$name" "$dest" 2>/dev/null || true
codesign --force --sign - "$dest" >/dev/null 2>&1 || true
bundle_deps "$dest"
fi
# Rewrite matching absolute / @rpath references on the target.
while IFS= read -r changed; do
changed="$(echo "$changed" | awk '{print $1}')"
case "$changed" in
/*|@rpath/*)
if [[ "$(basename "$changed")" == "$name" ]] || \
[[ "$(basename "$changed")" == "$(basename "$real")" ]] || \
{ [[ "$name" == "libjpeg.8.dylib" ]] && [[ "$(basename "$changed")" == libjpeg* ]]; }; then
install_name_tool -change "$changed" \
"@executable_path/../Frameworks/$name" "$target" 2>/dev/null || true
fi
;;
esac
done < <(otool -L "$target" | tail -n +2)
done < <(otool -L "$target" | tail -n +2)
}
echo "==> Bundling GTK / GLib dylibs into Frameworks"
bundle_deps "$MACOS/maturita-bin"
codesign --force --sign - "$MACOS/maturita-bin" >/dev/null 2>&1 || true
echo "==> Verifying Frameworks completeness"
MISSING=0
check_file_deps() {
local file="$1"
local line dep name
while IFS= read -r line; do
dep="$(echo "$line" | awk '{print $1}')"
case "$dep" in
@executable_path/../Frameworks/*)
name="$(basename "$dep")"
if [[ ! -f "$FW/$name" ]]; then
echo "MISSING: $name (required by $(basename "$file"))" >&2
MISSING=1
fi
;;
@rpath/*)
echo "UNRESOLVED @rpath: $dep (in $(basename "$file"))" >&2
MISSING=1
;;
/opt/homebrew/*|/usr/local/*)
echo "UNBUNDLED absolute dep still present: $dep (in $(basename "$file"))" >&2
MISSING=1
;;
esac
done < <(otool -L "$file" | tail -n +2)
}
check_file_deps "$MACOS/maturita-bin"
for f in "$FW"/*.dylib; do
check_file_deps "$f"
done
if [[ "$MISSING" -ne 0 ]]; then
echo "error: macOS bundle is incomplete" >&2
exit 1
fi
echo " Frameworks: $(ls "$FW" | wc -l | tr -d ' ') dylibs"
# Copy GLib / GTK runtime data (schemas, modules, icon themes).
copy_pkg_share() {
local prefix
prefix="$(pkg-config --variable=prefix "$1" 2>/dev/null || true)"
[[ -n "$prefix" && -d "$prefix/share" ]] || return 0
mkdir -p "$RES/share"
for sub in glib-2.0 gtk-4.0 icons themes locale; do
if [[ -d "$prefix/share/$sub" ]]; then
rsync -a "$prefix/share/$sub" "$RES/share/" 2>/dev/null \
|| cp -R "$prefix/share/$sub" "$RES/share/"
fi
done
}
copy_pkg_share gtk4
copy_pkg_share glib-2.0
if [[ -d "$RES/share/glib-2.0/schemas" ]] && command -v glib-compile-schemas >/dev/null 2>&1; then
glib-compile-schemas "$RES/share/glib-2.0/schemas" || true
fi
# Launcher sets GTK search paths then execs the real binary.
cat > "$MACOS/maturita" <<'EOF'
#!/bin/bash
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
export DYLD_LIBRARY_PATH="$ROOT/Frameworks${DYLD_LIBRARY_PATH:+:$DYLD_LIBRARY_PATH}"
export XDG_DATA_DIRS="$ROOT/Resources/share${XDG_DATA_DIRS:+:$XDG_DATA_DIRS}"
export GSETTINGS_SCHEMA_DIR="$ROOT/Resources/share/glib-2.0/schemas"
export GTK_DATA_PREFIX="$ROOT/Resources"
export GTK_EXE_PREFIX="$ROOT"
export XDG_DATA_HOME="$ROOT/Resources"
exec "$ROOT/MacOS/maturita-bin" "$@"
EOF
chmod +x "$MACOS/maturita"
cat > "$APP/Contents/Info.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>maturita.c</string>
<key>CFBundleDisplayName</key>
<string>maturita.c</string>
<key>CFBundleIdentifier</key>
<string>org.maturita.Maturita</string>
<key>CFBundleVersion</key>
<string>${VERSION:-1.0}</string>
<key>CFBundleShortVersionString</key>
<string>${VERSION:-1.0}</string>
<key>CFBundleExecutable</key>
<string>maturita</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key>
<string>12.0</string>
<key>NSHighResolutionCapable</key>
<true/>
</dict>
</plist>
EOF
if command -v sips >/dev/null 2>&1 && command -v iconutil >/dev/null 2>&1; then
ICONSET="$RES/AppIcon.iconset"
mkdir -p "$ICONSET"
for sz in 16 32 64 128 256 512; do
sips -z "$sz" "$sz" "$ICON_512" --out "$ICONSET/icon_${sz}x${sz}.png" >/dev/null
sips -z $((sz * 2)) $((sz * 2)) "$ICON_512" \
--out "$ICONSET/icon_${sz}x${sz}@2x.png" >/dev/null
done
iconutil -c icns -o "$RES/AppIcon.icns" "$ICONSET" && rm -rf "$ICONSET" || true
fi
# Ad-hoc sign the .app so Gatekeeper is less likely to kill dyld loads.
codesign --force --deep --sign - "$APP" >/dev/null 2>&1 || true
echo "==> Smoke-testing launch"
if [[ -n "${GITHUB_ACTIONS:-}${CI:-}" ]]; then
echo " (CI: skipping GUI smoke test; Frameworks verification already passed)"
else
SMOKE_LOG="$(mktemp)"
"$MACOS/maturita" >"$SMOKE_LOG" 2>&1 &
SMOKE_PID=$!
sleep 3
if ! kill -0 "$SMOKE_PID" 2>/dev/null; then
if grep -q "Library not loaded" "$SMOKE_LOG"; then
echo "error: Maturita.app missing bundled libraries" >&2
cat "$SMOKE_LOG" >&2 || true
exit 1
fi
echo "error: Maturita.app exited during smoke test" >&2
cat "$SMOKE_LOG" >&2 || true
exit 1
fi
kill "$SMOKE_PID" 2>/dev/null || true
wait "$SMOKE_PID" 2>/dev/null || true
rm -f "$SMOKE_LOG"
echo " smoke test OK (process stayed up)"
fi
echo "==> Zipping"
rm -f "$ROOT/dist/$OUT_ZIP"
(
cd "$ROOT/dist"
zip -qry "$OUT_ZIP" "$APP_NAME"
)
echo "Created dist/$OUT_ZIP"

85
desktop/scripts/gen-icons.py Executable file
View file

@ -0,0 +1,85 @@
#!/usr/bin/env python3
"""Regenerate assets/app-icon.ico and share/icons from assets/app-icon.png.
Uses macOS `sips` when available; otherwise requires Pillow.
Run from the repository root.
"""
from __future__ import annotations
import struct
import subprocess
import sys
import tempfile
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
SRC = ROOT / "assets" / "app-icon.png"
ICO = ROOT / "assets" / "app-icon.ico"
ICO_SIZES = [16, 32, 48, 64, 128, 256]
SHARE_SIZES = [16, 32, 48, 64, 128, 256, 512]
def resize_png(src: Path, dest: Path, size: int) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
if subprocess.call(["which", "sips"], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL) == 0:
subprocess.check_call(
["sips", "-z", str(size), str(size), str(src), "--out", str(dest)],
stdout=subprocess.DEVNULL,
)
return
try:
from PIL import Image
except ImportError as exc:
raise SystemExit(
"Need macOS sips or Pillow to resize icons"
) from exc
img = Image.open(src).convert("RGBA")
img = img.resize((size, size), Image.Resampling.LANCZOS)
img.save(dest, format="PNG")
def write_ico(entries: list[tuple[int, bytes]], dest: Path) -> None:
count = len(entries)
header = struct.pack("<HHH", 0, 1, count)
offset = 6 + 16 * count
direntries = []
blobs = []
for size, data in entries:
w = 0 if size >= 256 else size
h = 0 if size >= 256 else size
direntries.append(
struct.pack("<BBBBHHII", w, h, 0, 0, 1, 32, len(data), offset)
)
blobs.append(data)
offset += len(data)
dest.write_bytes(header + b"".join(direntries) + b"".join(blobs))
def main() -> int:
if not SRC.is_file():
print(f"missing {SRC}", file=sys.stderr)
return 1
with tempfile.TemporaryDirectory(prefix="maturita-ico-") as tmp:
entries: list[tuple[int, bytes]] = []
for size in ICO_SIZES:
png = Path(tmp) / f"icon_{size}.png"
resize_png(SRC, png, size)
entries.append((size, png.read_bytes()))
write_ico(entries, ICO)
print(f"wrote {ICO.relative_to(ROOT)}")
for size in SHARE_SIZES:
dest = ROOT / "share" / "icons" / "hicolor" / f"{size}x{size}" / "apps" / "maturita.png"
resize_png(SRC, dest, size)
print(f"wrote {dest.relative_to(ROOT)}")
assets_icon = ROOT / "assets" / "icons" / "hicolor" / "512x512" / "apps" / "maturita.png"
resize_png(SRC, assets_icon, 512)
print(f"wrote {assets_icon.relative_to(ROOT)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

342
desktop/settings.c Normal file
View file

@ -0,0 +1,342 @@
#include "maturita.h"
/* ------------------------------------------------------------------ */
/* Settings */
/* ------------------------------------------------------------------ */
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();
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);
{
GtkWidget *sw = gtk_scrolled_window_new();
gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(sw),
GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC);
gtk_scrolled_window_set_max_content_height(GTK_SCROLLED_WINDOW(sw), 470);
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;
}

View file

@ -0,0 +1,14 @@
[Desktop Entry]
Type=Application
Version=1.0
Name=maturita.c
GenericName=Maturita study app
GenericName[cs]=Příprava na maturitu
Comment=Educational GTK app for maturita exam preparation
Comment[cs]=Vzdělávací GTK aplikace pro přípravu na maturitu
Exec=maturita
Icon=maturita
Terminal=false
Categories=Education;GTK;
StartupNotify=true
StartupWMClass=org.maturita.Maturita

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

414
desktop/stats.c Normal file
View file

@ -0,0 +1,414 @@
#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++;
}
}
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
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);
gtk_progress_bar_set_fraction(
GTK_PROGRESS_BAR(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
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);
gtk_progress_bar_set_fraction(
GTK_PROGRESS_BAR(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"));
gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(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"));
gtk_progress_bar_set_fraction(GTK_PROGRESS_BAR(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);
gtk_progress_bar_set_fraction(
GTK_PROGRESS_BAR(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);
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;
}

937
desktop/style.css Normal file
View file

@ -0,0 +1,937 @@
window {
background-color: @bg_base;
background-image: linear-gradient(160deg, @bg_crust 0%, @bg_base 40%, @bg_surface0 100%);
}
button {
background-image: none;
}
.app-title {
color: @fg_text;
font-weight: 700;
font-size: 14px;
}
.settings-btn,
.settings-btn:hover,
.settings-btn:active,
.settings-btn:checked,
.settings-btn:focus,
.settings-btn:focus-visible {
min-width: 34px;
min-height: 34px;
padding: 0;
border-radius: 999px;
background: transparent;
background-color: transparent;
background-image: none;
border: none;
outline: none;
box-shadow: none;
color: @fg_text;
}
.settings-btn:hover {
color: @accent;
background-color: alpha(@bg_surface0, 0.55);
}
.settings-btn:active {
color: @accent;
background-color: alpha(@bg_surface1, 0.4);
}
popover,
popover.background,
.settings-popover {
background: @bg_mantle;
background-color: @bg_mantle;
color: @fg_text;
border: 1px solid @bg_surface1;
border-radius: 18px;
box-shadow: 0 18px 40px alpha(@bg_crust, 0.45);
}
popover > contents,
.settings-popover > contents {
background: transparent;
background-color: transparent;
border: none;
border-radius: 18px;
box-shadow: none;
padding: 0;
}
.settings-box {
background-color: transparent;
color: @fg_text;
min-width: 292px;
padding: 16px 16px 14px 16px;
}
.settings-title {
color: @fg_text;
font-size: 17px;
font-weight: 800;
letter-spacing: -0.2px;
}
.settings-label {
color: @fg_overlay;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
}
.settings-seg {
background-color: alpha(@bg_surface0, 0.55);
border: none;
border-radius: 12px;
padding: 3px;
}
.mode-chip {
background-image: none;
background-color: transparent;
color: @fg_subtext;
border: none;
border-radius: 10px;
padding: 9px 12px;
font-size: 13px;
font-weight: 600;
min-width: 0;
box-shadow: none;
transition: background-color 140ms ease, color 140ms ease,
box-shadow 140ms ease;
}
.mode-chip:hover {
background-color: alpha(@bg_surface1, 0.28);
color: @fg_text;
}
.mode-chip:checked {
background-image: linear-gradient(135deg, @accent 0%, @accent2 100%);
color: @on_accent;
font-weight: 800;
box-shadow: 0 4px 12px alpha(@accent, 0.25);
}
.mode-chip:checked:hover {
background-image: linear-gradient(135deg, @accent 0%, @accent2 100%);
color: @on_accent;
}
.theme-grid {
background-color: transparent;
border: none;
padding: 0;
}
flowboxchild {
padding: 0;
margin: 0;
background: transparent;
border: none;
box-shadow: none;
}
.theme-card {
background-image: none;
background-color: transparent;
color: @fg_text;
border: none;
border-radius: 12px;
padding: 6px 4px;
min-width: 0;
box-shadow: none;
transition: background-color 140ms ease;
}
.theme-card:hover {
background-color: alpha(@bg_surface0, 0.45);
}
.theme-card:checked {
background-color: transparent;
box-shadow: none;
}
.theme-card:checked:hover {
background-color: alpha(@bg_surface0, 0.35);
}
.theme-card-name {
color: @fg_subtext;
font-size: 11px;
font-weight: 600;
}
.theme-card:checked .theme-card-name {
color: @fg_text;
font-weight: 700;
}
headerbar,
headerbar.titlebar,
.titlebar {
background-color: @bg_base;
background-image: none;
color: @fg_text;
box-shadow: none;
border: none;
}
windowcontrols button:not(:hover) {
color: @fg_text;
}
windowcontrols button:not(:hover) image {
color: @fg_text;
}
.heading {
color: @fg_text;
font-size: 32px;
font-weight: 800;
}
.card {
background-color: alpha(@bg_surface0, 0.92);
border: 1px solid @bg_surface1;
border-radius: 18px;
padding: 30px 34px;
color: @fg_text;
font-size: 16px;
}
.btn-primary {
background-image: linear-gradient(135deg, @accent 0%, @accent2 100%);
color: @on_accent;
font-size: 16px;
font-weight: 700;
padding: 12px 40px;
border: none;
border-radius: 999px;
box-shadow: 0 6px 18px alpha(@accent, 0.25);
transition: box-shadow 150ms ease, background-image 150ms ease;
}
.btn-primary:hover {
box-shadow: 0 8px 24px alpha(@accent, 0.45);
background-image: linear-gradient(135deg, @accent2 0%, @accent3 100%);
}
.btn-primary:active {
box-shadow: 0 3px 10px alpha(@accent, 0.35);
}
.roadmap-title {
color: @fg_text;
font-size: 28px;
font-weight: 800;
}
.roadmap-sub {
color: @fg_subtext;
font-size: 14px;
}
.back-btn {
min-width: 42px;
min-height: 42px;
padding: 0;
border-radius: 999px;
background-color: alpha(@bg_surface0, 0.9);
border: 1px solid @bg_surface1;
color: @fg_text;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.35);
transition: background-color 150ms ease, color 150ms ease,
border-color 150ms ease, box-shadow 150ms ease;
}
.back-btn:hover {
background-color: @bg_surface1;
border-color: @bg_surface2;
color: @accent;
box-shadow: 0 6px 20px alpha(@accent, 0.35);
}
.back-btn:active {
background-color: @bg_surface0;
box-shadow: 0 2px 8px alpha(@bg_crust, 0.35);
}
.unit-node {
background-image: none;
background-color: @node_bg;
color: @fg_text;
border: 2px solid @locked_border;
border-radius: 999px;
padding: 0;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.35);
transition: transform 150ms ease, box-shadow 150ms ease,
border-color 150ms ease;
}
.unit-node.done {
background-image: linear-gradient(135deg, @success 0%, @success2 100%);
border-color: @success;
color: @on_accent;
box-shadow: 0 4px 16px alpha(@success, 0.35);
}
.unit-node.current {
background-image: linear-gradient(135deg, @accent 0%, @accent2 55%, @accent3 100%);
border-color: @accent;
color: @on_accent;
box-shadow: 0 4px 18px alpha(@accent, 0.5),
0 0 0 4px alpha(@bg_base, 0.9),
0 0 0 7px alpha(@accent, 0.45);
}
.unit-node.done:hover,
.unit-node.current:hover {
transition: box-shadow 150ms ease, border-color 150ms ease;
}
.unit-node.done:hover {
box-shadow: 0 8px 22px alpha(@success, 0.5);
}
.unit-node.current:hover {
box-shadow: 0 8px 26px alpha(@accent, 0.65),
0 0 0 4px alpha(@bg_base, 0.9),
0 0 0 7px alpha(@accent, 0.55);
}
.unit-node.locked {
background-image: none;
background-color: @locked_bg;
border: 2px solid @locked_border;
color: @fg_overlay;
box-shadow: none;
}
.unit-node.locked:hover {
transform: none;
box-shadow: none;
}
.unit-node.finish {
background-color: transparent;
background-image: none;
border: none;
color: @fg_overlay;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.35);
}
.unit-node.finish:hover {
transform: none;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.35);
}
.unit-node:focus,
.unit-node:focus-visible,
.unit-node:hover {
outline: none;
}
.unit-number {
font-size: 24px;
font-weight: 800;
}
.unit-node.done .unit-number,
.unit-node.current .unit-number {
color: @on_accent;
}
.unit-node.locked .unit-number {
color: @fg_overlay;
}
.state-icon {
color: @on_accent;
}
.unit-node.locked .lock-icon {
color: @bg_surface2;
}
.unit-name {
color: @fg_text;
font-size: 13px;
font-weight: 600;
}
.unit-name-locked {
color: @fg_overlay;
}
.ex-bubble {
min-width: 64px;
min-height: 64px;
padding: 0;
border-radius: 999px;
background-color: @node_bg;
color: @fg_text;
border: 2px solid @locked_border;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.35);
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.ex-bubble:hover {
background-image: none;
border-color: @accent;
box-shadow: 0 6px 20px alpha(@accent, 0.35);
}
.ex-bubble.done {
background-image: linear-gradient(135deg, @success 0%, @success2 100%);
border-color: @success;
color: @on_accent;
box-shadow: 0 4px 16px alpha(@success, 0.35);
}
.ex-bubble.done:hover {
box-shadow: 0 8px 22px alpha(@success, 0.5);
}
.ex-bubble:focus,
.ex-bubble:focus-visible {
outline: none;
}
.ex-branch {
border-style: dashed;
border-color: @accent;
}
.ex-branch:hover {
border-style: solid;
}
.bubble-number {
font-size: 22px;
font-weight: 800;
}
.ex-bubble.done .bubble-number {
color: @on_accent;
}
.bubble-icon {
color: @on_accent;
}
.ex-label {
color: @fg_text;
font-size: 12px;
font-weight: 600;
}
.cz-label {
background-color: @bg_base;
padding: 1px 6px;
border-radius: 8px;
}
.book-card {
background-image: none;
background-color: alpha(@bg_surface0, 0.92);
border: 1px solid @bg_surface1;
border-radius: 18px;
padding: 20px 26px;
color: @fg_text;
box-shadow: 0 6px 18px alpha(@bg_crust, 0.22);
transition: box-shadow 160ms ease, border-color 160ms ease,
background-color 160ms ease;
}
.book-card:hover {
border-color: @accent;
background-color: alpha(@accent, 0.10);
box-shadow: 0 12px 30px alpha(@accent, 0.32);
}
.book-card:active {
background-color: alpha(@accent, 0.18);
box-shadow: 0 4px 12px alpha(@accent, 0.25);
}
.reading-hero {
opacity: 0.22;
}
.book-title {
color: @fg_text;
font-size: 24px;
font-weight: 800;
}
.book-author {
color: @accent2;
font-size: 15px;
font-weight: 700;
}
.book-sub {
color: @fg_subtext;
font-size: 14px;
}
.lit-link {
background-image: none;
background-color: alpha(@bg_surface0, 0.92);
border: 1px solid @bg_surface1;
border-radius: 16px;
padding: 14px 18px;
color: @fg_text;
box-shadow: 0 5px 16px alpha(@bg_crust, 0.18);
transition: box-shadow 160ms ease, border-color 160ms ease,
background-color 160ms ease;
}
.lit-link:hover {
border-color: @accent;
background-color: alpha(@accent, 0.10);
box-shadow: 0 10px 26px alpha(@accent, 0.30);
}
.lit-link:active {
background-color: alpha(@accent, 0.18);
box-shadow: 0 4px 12px alpha(@accent, 0.25);
}
.lit-link-title {
color: @fg_text;
font-size: 17px;
font-weight: 800;
}
.lit-link-sub {
color: @fg_subtext;
font-size: 13px;
}
.lit-progress {
color: @accent2;
font-size: 13px;
font-weight: 800;
letter-spacing: 1px;
}
.lit-score {
color: @fg_subtext;
font-size: 13px;
font-weight: 700;
}
.lit-prompt {
color: @fg_text;
font-size: 20px;
font-weight: 800;
}
.lit-expl {
color: @success2;
font-size: 15px;
font-weight: 600;
background-color: alpha(@bg_surface0, 0.85);
border-left: 4px solid @success;
border-radius: 8px;
padding: 10px 14px;
}
.kahoot-opt {
background-image: none;
color: @on_accent;
font-size: 15px;
font-weight: 700;
padding: 16px;
border: 3px solid transparent;
border-radius: 14px;
box-shadow: 0 4px 14px alpha(@bg_crust, 0.30);
}
.kahoot-opt:hover {
box-shadow: 0 7px 22px alpha(@bg_crust, 0.42);
}
.kahoot-0 {
background-color: @accent;
}
.kahoot-1 {
background-color: @error;
}
.kahoot-2 {
background-color: @warning;
}
.kahoot-3 {
background-color: @success;
}
.kahoot-opt.ok {
border-color: @fg_text;
box-shadow: 0 0 0 3px alpha(@fg_text, 0.35);
}
.kahoot-opt.wrong {
opacity: 0.35;
}
.ex-prompt {
color: @fg_text;
font-size: 16px;
font-weight: 500;
}
.trans-word {
color: @fg_text;
font-size: 24px;
font-weight: 700;
}
.ex-sub {
color: @fg_subtext;
font-size: 13px;
font-weight: 700;
}
.hint {
color: @fg_subtext;
font-size: 13px;
font-style: italic;
}
.meaning {
color: @fg_subtext;
font-size: 13px;
font-style: italic;
}
.u3-answer {
color: @success;
font-size: 13px;
font-weight: 700;
}
.notes-card {
background-color: alpha(@bg_surface0, 0.9);
border: 1px solid @bg_surface1;
border-radius: 24px;
padding: 24px 34px;
box-shadow: 0 10px 30px alpha(@bg_crust, 0.20);
}
.notes-kicker {
color: @accent2;
font-weight: 800;
letter-spacing: 2px;
}
.notes-title {
color: @fg_text;
font-weight: 800;
}
.notes-body {
color: @fg_text;
}
.notes-tip {
color: @success2;
font-weight: 700;
}
.dlg-speaker {
color: @accent;
font-weight: 700;
font-size: 15px;
}
.number-big {
color: @warning;
font-size: 26px;
font-weight: 800;
}
.hm-word {
color: @fg_text;
font-family: monospace;
font-size: 30px;
font-weight: 800;
}
.chain {
color: @warning;
font-size: 20px;
font-weight: 700;
letter-spacing: 1px;
}
entry {
background-color: @bg_mantle;
color: @fg_text;
border: 1px solid @bg_surface1;
border-radius: 10px;
padding: 8px 12px;
font-size: 15px;
caret-color: @accent;
}
entry:focus {
border-color: @accent;
}
combobox {
background-color: @bg_mantle;
color: @fg_text;
}
combobox button {
background-image: none;
background-color: @bg_mantle;
color: @fg_text;
border: 1px solid @bg_surface1;
border-radius: 10px;
padding: 4px 10px;
}
combobox button:hover {
background-image: none;
border-color: @accent;
}
combobox.answer-ok button {
border-color: @success;
background-color: alpha(@success, 0.12);
}
combobox.answer-wrong button {
border-color: @error;
background-color: alpha(@error, 0.12);
}
entry.answer-ok {
border-color: @success;
background-color: alpha(@success, 0.08);
}
entry.answer-wrong {
border-color: @error;
background-color: alpha(@error, 0.08);
}
.pill {
background-image: none;
background-color: @node_bg;
color: @fg_text;
border: 2px solid @locked_border;
border-radius: 999px;
padding: 6px 18px;
font-size: 15px;
transition: border-color 150ms ease, box-shadow 150ms ease,
background-color 150ms ease;
}
.pill:hover {
background-image: none;
background-color: @node_bg;
border-color: @accent;
box-shadow: none;
}
.pill:active {
background-image: none;
background-color: @bg_surface1;
box-shadow: none;
}
.pill:checked {
background-image: linear-gradient(135deg, @accent 0%, @accent2 100%);
color: @on_accent;
border-color: @accent;
font-weight: 700;
}
.pill.ok,
.pill.ok:checked {
background-image: linear-gradient(135deg, @success 0%, @success2 100%);
color: @on_accent;
border-color: @success;
box-shadow: 0 0 0 2px alpha(@success, 0.35);
}
.pill.wrong,
.pill.wrong:checked {
background-image: linear-gradient(135deg, @error 0%, #c45c5c 100%);
color: @on_accent;
border-color: @error;
box-shadow: 0 0 0 2px alpha(@error, 0.35);
}
.pill.hm-key {
padding: 4px 5px;
min-width: 32px;
font-size: 13px;
}
.quiz-card {
background-color: alpha(@bg_surface0, 0.85);
border: 1px solid @bg_surface1;
border-radius: 18px;
padding: 16px 18px 14px 18px;
box-shadow: 0 6px 18px alpha(@bg_crust, 0.18);
}
.quiz-heading {
color: @fg_text;
font-size: 18px;
font-weight: 700;
}
.quiz-intro {
color: @fg_subtext;
font-size: 14px;
font-style: italic;
}
.quiz-q {
color: @fg_text;
font-size: 16px;
font-weight: 700;
}
.quiz-options {
margin-top: 2px;
}
.quiz-pill {
border-radius: 14px;
padding: 12px 16px;
min-height: 44px;
}
.quiz-pill label {
font-weight: 500;
}
.quiz-pill:checked label {
font-weight: 700;
}
.chip {
background-image: none;
background-color: @bg_surface1;
color: @fg_text;
border: 1px solid @bg_surface2;
border-radius: 999px;
padding: 6px 14px;
font-size: 14px;
font-weight: 600;
transition: background-color 150ms ease, border-color 150ms ease;
}
.chip:hover {
background-image: none;
background-color: @bg_surface2;
border-color: @accent;
box-shadow: none;
}
.chip:active {
background-image: none;
background-color: @bg_surface2;
box-shadow: none;
}
.chip.ghost-host {
opacity: 0;
}
.group-btn {
background-image: none;
background-color: @node_bg;
color: @fg_text;
border: 2px solid @locked_border;
border-radius: 12px;
padding: 8px 18px;
font-size: 14px;
font-weight: 700;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.group-btn:hover {
background-image: none;
background-color: @node_bg;
border-color: @accent;
box-shadow: none;
}
.group-btn:active {
background-image: none;
background-color: @bg_surface1;
box-shadow: none;
}
.group-btn:checked {
background-image: linear-gradient(135deg, @accent3 0%, @success2 100%);
color: @on_accent;
border-color: @accent3;
}
.group-panel {
background-color: alpha(@bg_surface0, 0.5);
border: 2px dashed @bg_surface1;
border-radius: 14px;
padding: 10px;
}
.group-panel-label {
color: @fg_subtext;
font-size: 13px;
font-weight: 700;
}
.sent-group {
background-color: alpha(@bg_surface0, 0.5);
border: 1px solid @bg_surface1;
border-radius: 14px;
padding: 12px;
}
.sent-group.ok {
border-color: @success;
}
.sent-group.wrong {
border-color: @error;
}
.feedback-ok {
color: @success;
font-size: 15px;
font-weight: 700;
}
.feedback-err {
color: @error;
font-size: 15px;
font-weight: 700;
}
.feedback-warn {
color: @warning;
font-size: 15px;
font-weight: 700;
}
.stats-page {
padding: 8px 28px 24px 28px;
}
.stats-summary {
margin-top: 8px;
margin-bottom: 18px;
}
.stats-metric {
background-color: alpha(@bg_surface0, 0.92);
border: 1px solid @bg_surface1;
border-radius: 16px;
padding: 16px 18px;
min-width: 120px;
}
.stats-metric-value {
color: @fg_text;
font-size: 26px;
font-weight: 800;
letter-spacing: -0.4px;
}
.stats-metric-label {
color: @fg_overlay;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
margin-top: 4px;
}
.stats-overall-bar,
.stats-overall-bar trough,
.stats-unit-bar,
.stats-unit-bar trough {
min-height: 10px;
min-width: 1px;
border-radius: 6px;
background-color: alpha(@bg_surface1, 0.85);
border: none;
padding: 0;
}
.stats-overall-bar {
margin-bottom: 22px;
min-height: 12px;
}
.stats-overall-bar progress,
.stats-unit-bar progress {
min-width: 1px;
min-height: 10px;
border-radius: 6px;
background-image: linear-gradient(90deg, @accent 0%, @accent2 100%);
background-color: @accent;
border: none;
}
.stats-unit-bar.done progress {
background-image: linear-gradient(90deg, @success 0%, @success2 100%);
background-color: @success;
}
.stats-section {
color: @fg_overlay;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.8px;
margin-bottom: 10px;
}
.stats-subject {
margin-bottom: 20px;
}
.stats-subject-name {
color: @fg_text;
font-size: 16px;
font-weight: 800;
}
.stats-subject-count {
color: @fg_subtext;
font-size: 13px;
font-weight: 700;
}
.stats-subject-count.locked {
color: @fg_overlay;
}
.stats-subject-bar,
.stats-subject-bar trough {
min-height: 12px;
min-width: 1px;
border-radius: 6px;
background-color: alpha(@bg_surface1, 0.85);
border: none;
padding: 0;
}
.stats-subject-bar {
margin: 4px 0 0 0;
}
.stats-subject-bar progress {
min-width: 1px;
min-height: 10px;
border-radius: 6px;
background-image: linear-gradient(90deg, @accent 0%, @accent2 100%);
background-color: @accent;
border: none;
}
.stats-units {
margin-top: 8px;
border-left: 2px solid alpha(@fg_overlay, 0.22);
padding-left: 12px;
}
.stats-unit-row {
background-color: alpha(@bg_surface0, 0.72);
border: 1px solid @bg_surface1;
border-radius: 14px;
padding: 12px 14px;
margin-bottom: 8px;
}
.stats-unit-row.locked {
opacity: 0.55;
}
.stats-unit-row.done {
border-color: alpha(@success, 0.55);
}
.stats-unit-index {
color: @fg_overlay;
font-size: 13px;
font-weight: 700;
min-width: 22px;
}
.stats-unit-name {
color: @fg_text;
font-size: 15px;
font-weight: 700;
}
.stats-unit-count {
color: @fg_subtext;
font-size: 13px;
font-weight: 600;
}
.stats-unit-badge {
color: @success;
font-size: 12px;
font-weight: 700;
}
tooltip {
background-color: @bg_mantle;
border: 1px solid @bg_surface1;
border-radius: 10px;
color: @fg_text;
}
/* no-scale */
/* Rules below keep a fixed size (symbols/icons) even when the window grows. */
.bubble-number.branch-star {
font-size: 22px;
}

430
desktop/subjects.c Normal file
View file

@ -0,0 +1,430 @@
#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);
const Rgb mauve = color_from_hex(app_theme.accent);
double hx, hy;
(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);
sub_point(0.0, &hx, &hy);
draw_node_halo(cr, hx, hy, SUB_BUBBLE * 1.05,
mauve.r, mauve.g, mauve.b, 0.18);
cairo_new_path(cr);
sub_path(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);
}
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
desktop/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
desktop/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;
}

333
desktop/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. */
static char *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 = 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);
}