diff --git a/Readme.md b/Readme.md index 51512ad..0c777af 100644 --- a/Readme.md +++ b/Readme.md @@ -16,6 +16,17 @@ - [Workflow](#workflow) - [OS images checked](#os-images-checked) - [OS images to check](#os-images-to-check) +- [EARS Application](#ears-application) + - [11. Project Layout](#11-project-layout) + - [12. Single-Window Notebook Architecture](#12-single-window-notebook-architecture) + - [13. Glade Rules That Bite](#13-glade-rules-that-bite) + - [14. Generating Widgets From Code](#14-generating-widgets-from-code) + - [15. Loading cases.csv](#15-loading-casescsv) + - [16. File Paths — AppFile()](#16-file-paths--appfile) + - [17. Audio Playback](#17-audio-playback) + - [18. Build & Run](#18-build--run) + - [19. Debugging Autoconnect Failures](#19-debugging-autoconnect-failures) + - [20. Windows Deployment Differences](#20-windows-deployment-differences) --- @@ -379,4 +390,494 @@ ### OS images to check - antiX-26_x64-full -- xubuntu-26.04-minimal-amd64 \ No newline at end of file +- xubuntu-26.04-minimal-amd64 + + +--- + +# EARS Application + +Notes covering the GTK# port of the auscultation trainer: dynamic UI +generation from CSV, the notebook page structure, and the failure modes +that cost the most time. + +--- + +## 11. Project Layout + +Source lives in `program/`, build output in `program/bin/linux/`. +Runtime assets must sit **next to the .exe**, not next to the source. + +``` +program/ +├── Program.cs +├── MainWindow.cs root window + splash page +├── SettingWindow.cs settings page controller +├── SoundWindow.cs sound page controller +├── CaseDefinition.cs CSV row model + loader +├── WavePlayer.cs cross-platform wav playback +├── ArduinoConnection.cs +├── Language.cs +├── homepageallv3.glade +├── cases.csv +├── map/ +├── sound/ +└── bin/linux/ ← everything above (minus .cs) copied here + ├── program.exe + ├── homepageallv3.glade + ├── cases.csv + ├── map/ + └── sound/ +``` + +--- + +## 12. Single-Window Notebook Architecture + +One `GtkWindow` (`Root`) holds one `GtkNotebook` (`RootNoteBook`) with +tabs hidden. Each "screen" is a notebook page; navigation is just +`RootNoteBook.CurrentPage = N`. No second window is ever created. + +| Page | Index | Root widget | Controller | +|---|---|---|---| +| Splash | 0 | `SplashGrid` | `MainWindow` | +| Settings | 1 | `SettingGird` | `SettingsWindow` | +| Sound | 2 | `SoundBox` | `SoundWindow` | + +`SettingsWindow` and `SoundWindow` are **not** `Gtk.Window` subclasses +despite the names — they're plain controller classes that receive the +shared `Builder` and bind their own widgets: + +```csharp +public class SoundWindow +{ + [UI] private Label ConditionNameLabel = null; + [UI] private Grid SoundButtonGrid = null; + [UI] private Button SoundBackButton = null; + + public event EventHandler BackRequested; + + public SoundWindow(Builder builder) + { + builder.Autoconnect(this); + ... + } +} +``` + +Wired up in `MainWindow`'s private constructor: + +```csharp +_soundWindow = new SoundWindow(builder); +_soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash; +Option1Button.Clicked += OnNormalSessionClicked; +``` + +Controllers never touch the notebook directly — they raise +`BackRequested` and let `MainWindow` decide. Keeps navigation in one place. + +**All three controllers share one `Builder`.** Each `Autoconnect` call +binds only the ids matching that class's `[UI]` fields, which is exactly +why ids must be unique across the entire file (§13). + +--- + +## 13. Glade Rules That Bite + +### IDs must be unique file-wide and valid C# identifiers + +`Autoconnect` maps glade ids to field names by string match across the +**whole** builder, not per page. Two widgets sharing an id bind +unpredictably. + +| Broken | Why | Fixed | +|---|---|---| +| `Condition Name` | Space — never matches a field name | `ConditionNameLabel` | +| `BackButto` | Typo | `SoundBackButton` | +| `BackButton` on two pages | Duplicate across pages | `SettingBackButton` + `SoundBackButton` | + +A mismatch **fails silently** — the field stays `null` and you get a +`NullReferenceException` later, often several clicks away from the cause. +See §19 for the guard that catches this at startup. + +Verify before running: + +```bash +grep -o 'id="[^"]*"' homepageallv3.glade | sort | uniq -d +``` + +Any output is a duplicate id. + +### Placeholders are design-time only + +The hatched empty cells Glade shows in a `GtkGrid` save as +`` and are **ignored at load time**. A button placed at +`left-attach=2` in a designer grid with two empty columns to its left +ends up at column 0 in the running app — this is the classic +"button jumps to the left" bug. + +Never use placeholders for spacing or alignment. Use `halign` + `hexpand`. + +### Right-aligning a button + +Both properties are required: + +```xml +end +True +``` + +`hexpand` makes the cell consume the full row width; `halign=end` parks +the button at the right edge of that cell. `halign` alone does nothing +when the cell is only as wide as the button. + +In Glade: **Common** tab → *Horizontal Alignment* = `End`, +*Expand → Horizontal* = checked. + +### Scrolling a grid needs a Viewport + +`GtkGrid` doesn't implement `GtkScrollable`. Dropping one into a +`GtkScrolledWindow` requires an intermediate `GtkViewport` — Glade +inserts it automatically. Don't delete it. + +``` +SoundScroller GtkScrolledWindow hexpand + vexpand, packing expand=True +└─ GtkViewport (auto-added, required) + └─ SoundButtonGrid GtkGrid empty, column-homogeneous=True +``` + +`GtkFlowBox` *is* scrollable and wraps children automatically, but the +`gtk-sharp3` binding on Ubuntu is 2.99.x and may not expose it. Check +before relying on it: + +```bash +monop -r:/usr/lib/cli/gtk-sharp-3.0/gtk-sharp.dll Gtk.FlowBox +``` + +### Expand appears in two tabs + +For a widget inside a `GtkBox`, **Common → Expand** sets the widget's own +`hexpand`/`vexpand`, while **Packing → Expand** sets the box child +property. They are different things and both usually need setting. + +--- + +## 14. Generating Widgets From Code + +Leave the container **empty** in Glade and fill it at runtime. Three +rules, all of which produce silent failures when broken: + +```csharp +private void BuildButtons(IEnumerable labels, Action onPick) +{ + // 1. Remove AND destroy — Remove alone leaks the widget + foreach (var child in SoundButtonGrid.Children) + { + SoundButtonGrid.Remove(child); + child.Destroy(); + } + + int i = 0; + foreach (string text in labels) + { + // 2. Capture the loop variable — otherwise every handler + // sees the final value + string captured = text; + + var btn = new Button(captured); + btn.Hexpand = true; + btn.Clicked += (s, e) => onPick(captured); + SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1); + i++; + } + + // 3. Widgets created in code start HIDDEN. Without this the grid + // stays blank with no error of any kind. + SoundButtonGrid.ShowAll(); +} +``` + +`ShowAll()` on the container is the single most common cause of +"my buttons didn't appear" in GTK#. + +### Drill-down navigation + +All three modes (type → condition → play) use one grid and one render +method. State is a `List` path; Back pops one level: + +```csharp +private readonly List _path = new List(); + +private void Render() +{ + var matches = _allCases.Where(MatchesPath).ToList(); + + var options = matches + .Where(c => c.TreePath.Length > _path.Count) + .Select(c => c.TreePath[_path.Count]) + .Distinct() + .ToList(); + + ConditionNameLabel.Text = _path.Count == 0 + ? "種別を選択" + : string.Join(" : ", _path); + + BuildButtons(options, picked => { _path.Add(picked); /* leaf? play : Render(); */ }); +} + +private void OnBackClicked(object sender, EventArgs e) +{ + if (_path.Count > 0) { _path.RemoveAt(_path.Count - 1); Render(); } + else { WavePlayer.Stop(); BackRequested(this, EventArgs.Empty); } +} +``` + +Tree depth varies per row (`Tree_Level3` is often empty), so +`CaseDefinition.TreePath` returns only the non-empty levels and the +drill-down adapts automatically. + +--- + +## 15. Loading cases.csv + +`CaseDefinition.LoadCasesFromCsv(path)` returns `List`. +Column → property mapping: + +| CSV column | Property | +|---|---| +| `Number` | `Number` | +| `Type` | `Type` (心音 / 呼吸音) — also drives `IsHeart` | +| `Category` | `CategoryJp` | +| `Subcategory` | `SubcategoryJp` | +| `Location` | `LocationJp` | +| `Tree_Level1..3` | `TreeLevel1..3` | +| `Image_File` | `MapFront` | +| `Sound_File` | `SoundPath` | +| `Image_Right` / `Image_Left` / `Image_Back` | `MapRight` / `MapLeft` / `MapBack` | + +Four things the loader must get right: + +**Encoding.** The file contains Japanese. Read with `Encoding.UTF8` and +BOM detection — the default ANSI codepage produces mojibake that only +shows up on the rendered buttons. + +**Delimiter.** The file is TAB separated. Auto-detect so a re-export +from Excel as comma-separated doesn't break it: + +```csharp +char sep = lines[0].Contains("\t") ? '\t' : ','; +``` + +**Header mapping.** Build `name → index` from row 0 rather than +hardcoding positions, so adding or reordering columns is safe. + +**InvariantCulture on every numeric parse.** Same failure class as the +CSS scaling bug in §"scaling" — under `ja_JP`/`de_DE` the +culture-sensitive default silently misparses: + +```csharp +int.TryParse(get(f, "Number"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out number) +``` + +The loader prints its result on every run: + +``` +Loaded 98 cases from /path/to/bin/linux/cases.csv +``` + +`Loaded 0 cases` points at the path or the delimiter, not the UI. + +--- + +## 16. File Paths — AppFile() + +**Never use bare relative paths.** `Path.GetFullPath("cases.csv")` +resolves against the *working directory*, so the app works when launched +from `program/` and silently loads nothing from anywhere else. + +```csharp +public static string AppFile(string relative) +{ + string dir = Path.GetDirectoryName( + System.Reflection.Assembly.GetExecutingAssembly().Location); + return Path.Combine(dir, relative); +} +``` + +Use it for **everything**: the glade file, the CSV, maps, sounds. + +```csharp +builder.AddFromFile(SoundWindow.AppFile("homepageallv3.glade")); +``` + +Mixing the two conventions is how you end up editing +`program/homepageallv3.glade` while the app reads +`bin/linux/homepageallv3.glade` — every fix appears to do nothing. + +**Linux is case-sensitive.** `sound/snd200.wav` ≠ `sound/SND200.wav`, +which was fine on Windows and isn't now. Audit the CSV against the disk: + +```bash +cut -f10 cases.csv | tail -n +2 | while read f; do + [ -n "$f" ] && [ -f "$f" ] || echo "MISSING: $f" +done +``` + +--- + +## 17. Audio Playback + +XAudio2/SharpDX from the WinForms build does **not** load under Mono on +Linux. `WavePlayer` shells out instead: + +| Platform | Mechanism | +|---|---| +| Linux / WSL | `paplay`, falling back to `aplay` | +| Windows | `System.Media.SoundPlayer` | + +Install both helpers — WSLg routes through PulseAudio, so `paplay` is +the one that works: + +```bash +sudo apt install pulseaudio-utils alsa-utils +``` + +Verify outside the app before blaming the C#: + +```bash +paplay bin/linux/sound/SND200.wav +echo $PULSE_SERVER # empty under WSLg → wsl --shutdown and retry +``` + +**Format matters on Windows.** `SoundPlayer` handles PCM WAV only: + +```bash +file sound/SND200.wav # want: RIFF ... WAVE audio, Microsoft PCM +``` + +**`Stop()` is a no-op on Windows** — `SoundPlayer.Play()` returns no +handle. If stop-on-back is needed there, use `PlaySync()` on a +background thread or add NAudio. + +--- + +## 18. Build & Run + +Debug build (keeps the console so `Console.WriteLine` diagnostics show): + +```bash +mcs -pkg:gtk-sharp-3.0 *.cs -out:./bin/linux/program.exe \ + && cp homepageallv3.glade cases.csv ./bin/linux/ \ + && cp -r map sound ./bin/linux/ \ + && mono ./bin/linux/program.exe +``` + +The `cp` steps are not optional — stale assets in `bin/linux/` are a +recurring source of phantom bugs (§16). + +Re-copying every wav on each build gets slow. Once stable, symlink and +drop the `cp -r`: + +```bash +ln -s ../../map bin/linux/map +ln -s ../../sound bin/linux/sound +``` + +Release build (`-target:winexe` suppresses the console — don't use it +while debugging): + +```bash +mcs -target:winexe -pkg:gtk-sharp-3.0 *.cs -out:./bin/windows/program.exe +``` + +--- + +## 19. Debugging Autoconnect Failures + +An unbound `[UI]` field is `null` with no warning. Add an explicit guard +to every controller constructor so the failure names the widget at +startup: + +```csharp +builder.Autoconnect(this); + +if (ConditionNameLabel == null || SoundButtonGrid == null || SoundBackButton == null) + throw new InvalidOperationException( + "Glade id mismatch — ConditionNameLabel=" + (ConditionNameLabel != null) + + " SoundButtonGrid=" + (SoundButtonGrid != null) + + " SoundBackButton=" + (SoundBackButton != null)); +``` + +Output looks like: + +``` +System.InvalidOperationException: Glade id mismatch — + ConditionNameLabel=False SoundButtonGrid=True SoundBackButton=True +``` + +For classes with many fields, loop a dictionary instead: + +```csharp +foreach (var pair in new Dictionary { + { "ComPortLabel", ComPortLabel }, { "ConnectButton", ConnectButton }, + { "SettingBackButton", SettingBackButton }, /* ... */ }) + if (pair.Value == null) + throw new InvalidOperationException("Glade id not bound: " + pair.Key); +``` + +### Reading a GTK# stack trace + +Exceptions inside signal handlers arrive wrapped: + +``` +System.Reflection.TargetInvocationException ---> System.NullReferenceException + at SoundWindow.Render () [0x0007b] +``` + +Ignore the `GLib.SignalClosure` / `MarshalCallback` frames — they're +plumbing. The first frame naming **your** class is the real site, and +the `[0x...]` IL offset distinguishes lines within it. + +### Common failures + +| Symptom | Cause | Fix | +|---|---|---| +| NRE in controller ctor | `[UI]` field id mismatch | §19 guard, then fix the glade id | +| NRE on first click, no CSV log line | Controller never constructed | Assign it in the **private** `MainWindow(Builder)` ctor | +| Buttons don't appear, no error | Missing `ShowAll()` | Call it on the container after `Attach` | +| Every button does the same thing | Closure over loop variable | `string captured = text;` | +| `Loaded 0 cases` | Wrong path or delimiter | Use `AppFile()`; check tab vs comma | +| Button sits left despite `halign=end` | Relying on grid placeholders | Add `hexpand=True` | +| Fix appears to do nothing | Editing a different copy of the glade | Use `AppFile()` + `cp` on build | +| Japanese renders as garbage | Wrong encoding | `File.ReadAllLines(path, Encoding.UTF8)` | +| GTK warning about scrolling | Grid directly in ScrolledWindow | Keep the `GtkViewport` | + +--- + +## 20. Windows Deployment Differences + +The C#, the glade file, and the CSV are **identical**. Four differences: + +1. **Audio** — see §17. PCM-only, and `Stop()` doesn't work. +2. **Build target** — `-target:winexe` for no console window. +3. **Runtime** — Mono for Windows must be installed on the target + machine; it bundles the GTK# runtime. +4. **Serial ports** — `ArduinoConnection` enumerates `COM*` rather than + `/dev/ttyUSB*` / `/dev/ttyACM*`. Mono's Linux `SerialPort.GetPortNames()` + has historically missed devices; verify against `ls /dev/tty*`. + +Ship this folder: + +``` +program.exe +homepageallv3.glade +cases.csv +map/ +sound/ +``` + +Not problems, for the record: forward slashes in CSV paths work fine on +Windows, and Windows' case-insensitivity means anything working on Linux +also works there — never the reverse. Develop on Linux and Windows +comes free. \ No newline at end of file diff --git a/ReadmeQuick.md b/ReadmeQuick.md index f227bb0..768dd98 100644 --- a/ReadmeQuick.md +++ b/ReadmeQuick.md @@ -14,4 +14,9 @@ # Program referencing that DLL mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe + +#run with edit +GTK_DEBUG=interactive mono ./bin/linux/program.exe + ``` + diff --git a/program/MainWindow.cs b/program/MainWindow.cs index b68f449..16b6d65 100644 --- a/program/MainWindow.cs +++ b/program/MainWindow.cs @@ -14,6 +14,7 @@ private const int PageSplash = 0; private const int PageSettings = 1; + private const int PageSound = 2; // ---- splash page widgets ---- [UI] private Button Option1Button = null; @@ -45,12 +46,14 @@ internal SettingsWindow _settingsWindow; internal Language _Language; + private SoundWindow _soundWindow; + public MainWindow() : this(CreateBuilder()) { } private static Builder CreateBuilder() { var builder = new Builder(); - builder.AddFromFile("homepageall.glade"); + builder.AddFromFile("homepageallv3.glade"); return builder; } @@ -86,7 +89,6 @@ _arduino.ConnectionChanged += OnArduinoConnectionChanged; - // // _buttonsById = new Dictionary // // { // // { "Option1Button", Option1Button }, @@ -129,6 +131,9 @@ RootNoteBook.ShowTabs = false; RootNoteBook.ShowBorder = false; RootNoteBook.CurrentPage = PageSplash; + _soundWindow = new SoundWindow(builder); + _soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash; + OnArduinoConnectionChanged(false); } @@ -287,6 +292,8 @@ private void OnNormalSessionClicked(object sender, EventArgs e) { Console.WriteLine("Normal Session selected"); + RootNoteBook.CurrentPage = PageSound; + _soundWindow.Reset(); } private void OnPositionRecordingClicked(object sender, EventArgs e) diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs index f39a427..f091c74 100644 --- a/program/SettingWindow.cs +++ b/program/SettingWindow.cs @@ -10,7 +10,7 @@ [UI] private Button ConnectButton = null; [UI] private Button AutoConnectButton = null; [UI] private Button RefreshButton = null; - [UI] private Button BackButton = null; + [UI] private Button SettingBackButton = null; [UI] private Label StatusLabel = null; [UI] private Label LogLabel = null; [UI] private TextView LogtextBox = null; @@ -45,7 +45,7 @@ RefreshButton.Clicked += (s, e) => RefreshPorts(); ConnectButton.Clicked += OnConnectClicked; AutoConnectButton.Clicked += OnAutoConnectClicked; - BackButton.Clicked += OnBackClicked; + SettingBackButton.Clicked += OnBackClicked; // DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); }; @@ -57,7 +57,7 @@ _lang.Register("LogLabel", LogLabel); _lang.Register("AutoConnectButton", AutoConnectButton); _lang.Register("RefreshButton", RefreshButton); - _lang.Register("BackButton", BackButton); + _lang.Register("SettingBackButton", SettingBackButton); _lang.RegisterDynamic("ConnectButton", ConnectButton); // set in OnConnectionChanged _lang.RegisterDynamic("StatusLabel", StatusLabel); _lang.Changed += (s, e) => OnConnectionChanged(_connected); diff --git a/program/homepageall.glade b/program/homepageall.glade index 07009b2..03f30c2 100644 --- a/program/homepageall.glade +++ b/program/homepageall.glade @@ -352,4 +352,136 @@ + + False + 1280 + 800 + + + True + False + vertical + + + True + False + label + + + + + + False + True + 0 + + + + + + True + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + True + 1 + + + + + + True + False + + + Back + True + True + True + end + True + right + + + 2 + 0 + + + + + + + + + + + False + True + 2 + + + + + diff --git a/program/info_eng.csv b/program/info_eng.csv index a7bb3e8..0335a1a 100644 --- a/program/info_eng.csv +++ b/program/info_eng.csv @@ -10,7 +10,7 @@ LogLabel,Log AutoConnectButton,Auto Connect RefreshButton,Refresh Ports -BackButton,Back +SettingBackButton,Back ConnectButton.Connect,Connect ConnectButton.Disconnect,Disconnect StatusLabel.Connected,Connected ({0}) diff --git a/program/info_jpn.csv b/program/info_jpn.csv index 5cac763..eb576d7 100644 --- a/program/info_jpn.csv +++ b/program/info_jpn.csv @@ -10,7 +10,7 @@ LogLabel,ログ AutoConnectButton,自動接続 RefreshButton,ポート更新 -BackButton,戻る +SettingBackButton,戻る ConnectButton.Connect,接続 ConnectButton.Disconnect,切断 StatusLabel.Connected,接続済み ({0})