diff --git a/OS/.gitkeep b/OS/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/OS/.gitkeep diff --git a/OS/BACKUP/Readme.md b/OS/BACKUP/Readme.md new file mode 100644 index 0000000..4578fef --- /dev/null +++ b/OS/BACKUP/Readme.md @@ -0,0 +1,243 @@ +# Restoring Windows on ASUS T101HA + +Complete guide to reverting from Xubuntu back to the original Windows installation. + +--- + +## What you have + +| Item | Location | Purpose | +|---|---|---| +| `t101ha.img.zst` | PC: `E:\git\02 cSharp using mono\main\EARS-LINUX\OS\BACKUP\`
USB: BACKUP partition | Full byte-exact disk image, 17GB compressed | +| `parts.txt` | Same locations | Partition table reference | +| `window-key.txt` | Same locations | Windows product key | + +**Verified:** SHA256 `CBD4348ACB6777D5110D00F15FD67A6CBABE96152B628E503A8F2F6127E4EB63` (both copies identical) + +**Image expands to:** 62,537,072,640 bytes — exact match to device size + +--- + +## Device reference + +- **Internal eMMC:** `/dev/mmcblk1` — ~58GiB (62,537,072,640 bytes) +- **Firmware:** 64-bit UEFI (confirmed via `fw_platform_size`) +- **Original layout:** ESP, Microsoft reserved, Windows (`p3`), recovery (`p4`) + +> **WARNING:** Verify the device with `lsblk` every time. Device names can shift between boots. + +--- + +## Method 1 — Full image restore (recommended) + +Restores the exact original system: Windows activated, ASUS recovery partition, everything. + +### Preparation + +If the image lives on the PC, copy it back to the USB stick's BACKUP partition first. Restoring over a network is not recommended. + +### Steps + +**1. Boot the Ventoy live session** + +Power off fully. Hold F2, tap power, keep holding. Confirm Secure Boot is disabled, boot from USB, select the Xubuntu ISO, choose "Try Xubuntu". + +**2. Identify the devices** + +```bash +lsblk -o NAME,SIZE,FSTYPE,LABEL,MOUNTPOINT +``` + +Confirm: +- eMMC = the **~58GiB** device (`mmcblk1`) +- USB = the **~115GiB** device with Ventoy/VTOYEFI/BACKUP partitions + +**3. Mount the backup** + +```bash +sudo mkdir -p /mnt/backup +sudo mount /dev/sda3 /mnt/backup +ls -lh /mnt/backup +``` + +Adjust `sda3` to match the BACKUP partition from step 2. + +**4. Verify the image before writing** + +```bash +zstd -t /mnt/backup/t101ha.img.zst +``` + +Must complete without error. If it fails, use the PC copy instead — do not proceed with a corrupt image. + +**5. Restore** + +```bash +sudo blockdev --getsize64 /dev/mmcblk1 +``` + +Confirm this prints `62537072640`. Then: + +```bash +zstd -dc /mnt/backup/t101ha.img.zst | sudo dd of=/dev/mmcblk1 bs=4M status=progress +sync +``` + +> **WARNING:** `of=` is the destructive direction. Everything on that device is erased. Read the command twice before pressing Enter. + +Takes 1–2 hours. Do not interrupt it, and do not run anything else — 2GB RAM. + +**6. Finish** + +```bash +sync +sudo umount /mnt/backup +sudo poweroff +``` + +Remove the USB stick. Power on. Windows should boot, activated, exactly as it was. + +### With a progress bar + +```bash +sudo apt install -y pv +sudo sh -c 'zstd -dc /mnt/backup/t101ha.img.zst | pv -s 62537072640 | dd of=/dev/mmcblk1 bs=4M' +``` + +--- + +## Method 2 — Recover files only (non-destructive) + +To extract files from the Windows image without wiping Xubuntu. Requires ~58GB free space. + +```bash +zstd -d /path/to/t101ha.img.zst -o /tmp/full.img +sudo losetup -fP --show /tmp/full.img +``` + +Note the loop device it prints (e.g. `/dev/loop0`), then: + +```bash +sudo mkdir -p /mnt/win +sudo mount -o ro /dev/loop0p3 /mnt/win +``` + +Windows lives on partition 3. Browse `/mnt/win`, copy what you need. + +Cleanup: + +```bash +sudo umount /mnt/win +sudo losetup -d /dev/loop0 +rm /tmp/full.img +``` + +If mount fails with a BitLocker error, the partition is encrypted — you'll need the recovery key from https://account.microsoft.com/devices/recoverykey + +--- + +## Method 3 — Clean Windows install (image lost) + +Last resort. The product key is stored in firmware (ACPI MSDM table), so a clean install self-activates without entering anything. + +1. Download the Windows 10 ISO from Microsoft on the PC +2. Write it to USB with Rufus — **GPT**, **UEFI** target +3. Boot the tablet from it, install to the internal eMMC +4. Activation happens automatically from firmware + +**Caveats:** +- The ASUS recovery partition is not restored +- Cherry Trail drivers may need manual installation from ASUS support +- **Windows 10 is past end of support (October 2025), and this hardware cannot run Windows 11** — a restored image is a supported-nothing situation either way, but a clean install is strictly worse + +--- + +## Recovering the product key + +From Linux, on the running tablet: + +```bash +sudo strings /sys/firmware/acpi/tables/MSDM | tail -1 +``` + +From the saved file: + +```bash +cat /mnt/backup/window-key.txt; echo +``` + +From Windows: + +```powershell +(Get-CimInstance SoftwareLicensingService).OA3xOriginalProductKey +``` + +**Note:** viewing `window-key.txt` in Notepad may show CJK garbage — it's an encoding guess, not corruption. Read it on Linux with `cat`, or open it in VS Code and set encoding to UTF-8. + +--- + +## Verifying image integrity + +Quick check (structural): + +```bash +zstd -t t101ha.img.zst +``` + +Full check (byte count vs device): + +```bash +DEV=$(sudo blockdev --getsize64 /dev/mmcblk1) +IMG=$(zstd -dc /path/to/t101ha.img.zst | wc -c) +echo "device: $DEV" +echo "image: $IMG" +test "$DEV" = "$IMG" && echo MATCH || echo MISMATCH +``` + +Compare copies (PowerShell): + +```powershell +Get-FileHash 'path\to\t101ha.img.zst' -Algorithm SHA256 +``` + +> **WARNING:** `[ "$DEV" = "$IMG" ]` requires spaces inside the brackets. Without them the shell reports "command not found" and prints MISMATCH regardless of the actual values. Use `test` to avoid this. + +--- + +## Troubleshooting + +**Restore finishes but won't boot** + +Check Secure Boot in BIOS — re-enable it, since the original Windows install expects it. Confirm the boot order lists Windows Boot Manager. + +**`dd` reports "No space left on device"** + +Wrong target device. Verify with `lsblk` that you're writing to the ~58GiB eMMC. + +**Image fails `zstd -t`** + +Use the other copy. If both fail, fall back to Method 3. + +**Progress appears frozen** + +Check it's actually stalled before interrupting: + +```bash +ps -eo pid,stat,cmd | grep -v grep | grep -E 'dd|zstd' +sudo dmesg | tail -30 +``` + +STAT `D` plus `mmc` errors in dmesg indicates bad sectors. STAT `R`/`S` with a clean dmesg means it's just slow — eMMC on this tablet throttles when warm. + +**"read kernel buffer failed"** + +`dmesg` needs root here: `sudo dmesg` + +--- + +## Notes + +- Keep both copies of the image. The USB stick is a single point of failure. +- Restoring reverts to the disk state as of **27 July 2026**. Nothing created in Xubuntu survives. +- Do a full `zstd -t` on whichever copy you plan to use *before* wiping anything. +- Original image created with: `sudo zstd -1 -T0 -f -o /mnt/backup/t101ha.img.zst /dev/mmcblk1` \ No newline at end of file diff --git a/OS/Readme.md b/OS/Readme.md new file mode 100644 index 0000000..c9bb2f5 --- /dev/null +++ b/OS/Readme.md @@ -0,0 +1,746 @@ +# Xubuntu Setup — Asus T101HA (`ears-tablet`) + +Working notes for setting up an Asus T101HA (Atom x5-Z8350, 2 GB RAM / ~1.8 GB +usable, eMMC) as a Mono + GTK#3 development and runtime machine. + +User: `ears` · Hostname: `ears-tablet` + +--- + +## Contents + +- [0. Recommended baseline](#0-recommended-baseline) +- [1. Installing Xubuntu](#1-installing-xubuntu) + - [1.1 Check firmware bitness first](#11-check-firmware-bitness-first--this-determines-everything) + - [1.2 Build the USB](#12-build-the-usb) + - [1.3 BIOS](#13-bios) + - [1.4 Install](#14-install) + - [1.5 Post-install GRUB fix (IA32 only)](#15-post-install-grub-fix-ia32-machines-only) + - [1.6 Expect to fix afterwards](#16-expect-to-fix-afterwards) +- [2. First boot / recovery mode](#2-first-boot--recovery-mode) +- [3. Low-RAM tuning](#3-low-ram-tuning) + - [Option A — `zram-tools`](#option-a--zram-tools-simple) + - [Option B — `systemd-zram-generator`](#option-b--systemd-zram-generator-current-preferred-on-2404) + - [Swappiness](#swappiness) +- [4. apt: repair and sources](#4-apt-repair-and-sources) + - [4.1 The modern layout](#41-the-modern-layout) + - [4.2 Correct stock sources](#42-correct-stock-sources-2510-example--substitute-your-codename) + - [4.3 Which host? archive vs old-releases](#43-which-host-archive-vs-old-releases) + - [4.4 Check for genuinely broken packages](#44-check-for-genuinely-broken-packages) + - [4.5 Don't do these](#45-dont-do-these) + - [4.6 `full-upgrade` checklist](#46-full-upgrade-checklist) + - [4.7 Reboot needed?](#47-reboot-needed) +- [5. Mono + GTK#3](#5-mono--gtk3) + - [Install](#install) + - [Optional](#optional) + - [Verify](#verify) + - [Smoke test](#smoke-test) + - [Notes vs. the WSL guide](#notes-vs-the-wsl-guide) +- [6. Display rotation + touchscreen](#6-display-rotation--touchscreen) + - [Identify devices](#identify-devices) + - [Matrices](#matrices) + - [Persistence script](#persistence-script) + - [Login screen (LightDM)](#login-screen-lightdm--separate-from-your-session) + - [Better: rotate before the session starts](#better-rotate-before-the-session-starts) + - [Wallpaper breaks after rotating](#wallpaper-breaks-after-rotating) + - [Auto-rotation (optional)](#auto-rotation-optional) +- [7. Audio](#7-audio) + - [Install and test](#install-and-test) + - [Choosing the right card](#choosing-the-right-card) + - [Renumbering at the kernel level](#renumbering-at-the-kernel-level) + - [Caveats](#caveats) +- [8. Running a script at boot](#8-running-a-script-at-boot) +- [9. File transfer to/from Windows](#9-file-transfer-tofrom-windows) +- [10. Quick reference](#10-quick-reference) + +--- + +## 0. Recommended baseline + +**Target: Xubuntu 24.04 LTS (Minimal ISO).** + +Reasons, from the apt/Mono investigation: +- The machine was found running **Ubuntu 25.10 "questing"**, which hit end of + life on **9 July 2026**. No further updates will ever be published for it. +- `gtk-sharp3` is **not packaged on 25.10**, and upgrading to 26.04 would not + bring it back. +- `gtk-sharp3` **is** packaged on 24.04 (noble), supported to 2029. +- Xfce on 24.04 idles around 500–600 MB, which matters at 1.8 GB RAM. + +Mono + `mcs` + GTK# is the correct toolchain for this hardware — the .NET SDK +(~800 MB) and Avalonia are far heavier. + +Before reinstalling, back up: `*.cs`, `*.glade`, SSH keys, browser profile. + +```bash +lsblk # is /home a separate partition? +df -h / +free -h +mkdir -p ~/backup && cp ~/*.cs ~/*.glade ~/backup/ +``` + +If `/home` is its own partition, it can be preserved during install (assign +`/home` **without** format). Use the same username to keep permissions clean. + +--- + +## 1. Installing Xubuntu + +### 1.1 Check firmware bitness first — this determines everything + +In Windows: **Settings → System → About → System type** + +- *32-bit operating system, x64-based processor* → **IA32 UEFI**, needs the + bootia32 workaround below. +- *64-bit* → skip the GRUB steps. + +### 1.2 Build the USB + +Ventoy includes IA32 UEFI support, so try it first. If it won't boot, fall back +to Rufus and copy a 32-bit `bootia32.efi` (GRUB) into `/EFI/BOOT` on the stick. + +From Linux, writing directly: + +```bash +sudo dd if=xubuntu-24.04-minimal-amd64.iso of=/dev/sdX bs=4M status=progress conv=fsync +``` + +> Verify the target with `lsblk` first. `dd` to the wrong disk destroys it +> silently. + +Verify the ISO: `sha256sum` against the checksum on the download page. + +### 1.3 BIOS + +- F2 at power-on +- **Disable Secure Boot** (the ia32 GRUB is unsigned) +- Boot menu is usually F12 / Esc + +### 1.4 Install + +Choose **Install**, not *Try* — the live session eats RAM you don't have. +Don't open Firefox or anything else during install. Let it create swap. + +### 1.5 Post-install GRUB fix (IA32 machines only) + +The installer writes a 64-bit `grubx64.efi` the firmware can't read. Boot the +live session, chroot in, and: + +```bash +grub-install --target=i386-efi --efi-directory=/boot/efi +``` + +This is **not optional** on IA32 firmware. + +### 1.6 Expect to fix afterwards + +| Component | Notes | +|---|---| +| Wi-Fi | RTL8723BS SDIO — finicky | +| Audio | ESS ES8316 codec, needs UCM configs | +| Touchscreen | Works, but rotation needs manual matrix (§6) | +| Auto-rotation | Needs `iio-sensor-proxy` + custom handler | + +A recent 6.x kernel fixes most of these. + +--- + +## 2. First boot / recovery mode + +If the user account lacks admin rights: + +```bash +usermod -aG sudo ears +groups ears # confirm 'sudo' appears +hostnamectl set-hostname ears-tablet +localectl set-locale LANG=en_US.UTF-8 # optional, cosmetic +exit # then 'resume' +``` + +`usermod -aG sudo` is the one that isn't optional — without it you can log in +but can't run any admin command. + +--- + +## 3. Low-RAM tuning + +### Option A — `zram-tools` (simple) + +```bash +sudo apt install zram-tools +``` + +Edit `/etc/default/zramswap`: +``` +PERCENT=60 +ALGO=zstd +``` + +### Option B — `systemd-zram-generator` (current, preferred on 24.04+) + +```bash +sudo apt install systemd-zram-generator + +sudo tee /etc/systemd/zram-generator.conf > /dev/null <<'EOF' +[zram0] +zram-size = min(ram / 2, 4096) +compression-algorithm = zstd +swap-priority = 100 +EOF + +sudo systemctl daemon-reload +sudo systemctl start systemd-zram-setup@zram0.service +swapon --show # expect /dev/zram0 +zramctl +``` + +The package does nothing without a config file containing at least one section. + +### Swappiness + +Swapping to zram is cheap, so raise it so the kernel actually uses it: + +```bash +echo 'vm.swappiness=100' | sudo tee /etc/sysctl.d/99-zram.conf +``` + +Reboot. Verdict: worth it at 2–4 GB, marginal at 8 GB, skip above 16 GB. +zram compresses **RAM** — it does nothing for low disk space. + +--- + +## 4. apt: repair and sources + +### 4.1 The modern layout + +Since 24.04, archive config lives in **`/etc/apt/sources.list.d/ubuntu.sources`** +(deb822 format). `/etc/apt/sources.list` being empty is normal, not a symptom. +Most apt advice online predates this and edits a file that does nothing. + +### 4.2 Correct stock sources (25.10 example — substitute your codename) + +```bash +sudo tee /etc/apt/sources.list.d/ubuntu.sources > /dev/null <<'EOF' +Types: deb +URIs: http://archive.ubuntu.com/ubuntu/ +Suites: questing questing-updates questing-backports +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg + +Types: deb +URIs: http://security.ubuntu.com/ubuntu/ +Suites: questing-security +Components: main restricted universe multiverse +Signed-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg +EOF + +sudo apt update +``` + +Do not drop the `Signed-By:` line — without it apt falls back to the legacy +trusted-keyring and you get confusing "not signed" errors later. + +`ubuntu.sources.curtin.orig` is the installer's pristine backup. apt ignores +`.orig` files. Useful as a reference, don't copy blindly. + +### 4.3 Which host? archive vs old-releases + +An EOL release only moves to `old-releases.ubuntu.com` when the migration +actually runs — which can lag the EOL date by weeks. Pointing there too early +gives four clean 404s. Test before switching: + +```bash +for p in questing questing-updates questing-backports questing-security; do + echo -n "$p: " + curl -sI "http://old-releases.ubuntu.com/ubuntu/dists/$p/Release" | head -1 +done + +curl -sI http://archive.ubuntu.com/ubuntu/dists/questing/Release | head -1 +``` + +`200` = pocket exists. `404` = drop it or use the other host. + +### 4.4 Check for genuinely broken packages + +All read-only, cheapest first: + +```bash +sudo dpkg --audit # clean = no output at all +sudo apt-get check # verifies the dependency tree +dpkg -l | grep -v '^ii' | grep -v '^rc' +apt-mark showhold # held packages are silently skipped on upgrade +``` + +dpkg status codes (desired state, then actual): + +| Code | Meaning | +|---|---| +| `ii` | Healthy | +| `rc` | Removed, config left — harmless | +| `iU` | Unpacked, never configured | +| `iF` | Half-configured | +| `iH` | Half-installed | +| `iW` / `it` | Waiting on / pending triggers | +| `*R*` | Reinstall required → `apt install --reinstall ` | + +Then repair: + +```bash +sudo dpkg --configure -a +sudo apt --fix-broken install +sudo apt full-upgrade +``` + +### 4.5 Don't do these + +```bash +# WRONG — killall receives 'sudo', 'rm', paths etc. as process names +sudo killall apt apt-get dpkg 2>/dev/null sudo rm /var/lib/apt/lists/lock ... + +# DANGEROUS — rm -rf also targets files named sudo, apt, clean, update in $PWD +sudo rm -rf /var/lib/apt/lists/* sudo apt clean sudo apt update +``` + +These must be separate lines. Also: killing `dpkg` mid-transaction is what +*creates* the broken state. Check first with `ps aux | grep -E 'apt|dpkg'`. + +Don't remove `universe` — it's an Ubuntu component, not a PPA, and +`gtk-sharp3` lives there. And never run +`add-apt-repository --remove ppa:repository-name/ppa` literally; that's a +placeholder from a copied guide. + +### 4.6 `full-upgrade` checklist + +- Use `full-upgrade`, not `upgrade` — it permits removals where deps changed +- Read the summary line; mass removals of desktop/kernel packages = stop +- Plug the tablet in; power loss mid-`dpkg` recreates the broken state +- On config prompts, keeping the local version (the default) is safe unless + you know you edited it +- Then `sudo apt autoremove` — but answer `n` if anything with `efi` appears +- Reboot before `do-release-upgrade` so the newest kernel is running + +### 4.7 Reboot needed? + +```bash +[ -f /var/run/reboot-required ] && cat /var/run/reboot-required.pkgs +sudo needrestart # interactive; shows what's running old libs +``` + +Kernel / initramfs / systemd / glibc → reboot. Everything else → restart the +service. + +--- + +## 5. Mono + GTK#3 + +### Install + +```bash +sudo add-apt-repository universe +sudo apt update +sudo apt install mono-complete gtk-sharp3 +``` + +| Package | Contents | Size | +|---|---|---| +| `mono-runtime` | JIT + `mscorlib` only | ~15 MB | +| `mono-devel` | Compiler + the BCL you need | ~200–400 MB | +| `mono-complete` | Runtime + `mcs` + entire class library + F#/VB | ~300–500 MB | + +**Avoid `mono-runtime`.** The BCL is split across dozens of +`libmono-system-*-cil` packages; hello-world runs, but anything touching LINQ, +XML, HTTP, or `System.IO.Ports` (the COM-port combo box in `homepage.glade`) +throws `Could not load file or assembly`. On a tight disk, `mono-devel` saves +~100 MB over `mono-complete` and still covers everything this project needs. + +### Optional + +```bash +sudo apt install glade # visual designer — skip on this machine +sudo apt install libgdiplus # only if using System.Drawing +sudo apt install libcanberra-gtk3-module # silences a harmless console warning +``` + +### Verify + +```bash +mono --version +mcs --version +pkg-config --list-all | grep gtk-sharp # want gtk-sharp-3.0 +apt policy gtk-sharp3 +``` + +### Smoke test + +```bash +cat > gtkcheck.cs <<'EOF' +using System; +using Gtk; + +class GtkCheck { + static void Main() { + Application.Init(); + var win = new Window("Hello GTK#3"); + win.SetDefaultSize(300, 200); + win.DeleteEvent += (o, args) => Application.Quit(); + + var button = new Button("Click me"); + button.Clicked += (o, args) => Console.WriteLine("Clicked!"); + win.Add(button); + + win.ShowAll(); + Application.Run(); + } +} +EOF + +mcs -pkg:gtk-sharp-3.0 gtkcheck.cs +mono gtkcheck.exe +``` + +Do the throwaway test first — it isolates "is gtk-sharp3 working" from "does my +app compile." + +Then the real thing: + +```bash +mcs -pkg:gtk-sharp-3.0 program.cs MainWindow.cs ArduinoConnection.cs -out:test.exe +mono test.exe +``` + +(`ArduinoConnection.cs` is needed because `MainWindow` calls +`_arduino.Disconnect()`; omitting it gives `CS0246`.) + +### Notes vs. the WSL guide + +- No WSLg, no `$DISPLAY` fiddling — Xfce is itself GTK3, so the app picks up the + system theme and the window just appears. +- Only exception: over SSH you need `ssh -X`. +- Companion files (`-r:` DLLs, `homepage.glade`) must sit next to the `.exe`, or + you get `FileNotFoundException`. +- `homepage.glade` targets GTK 3.24, which 22.04 and 24.04 both ship. +- `gtk-sharp2` is gone from 24.04 onward — use `gtk-sharp3`. + +--- + +## 6. Display rotation + touchscreen + +X11 does **not** rotate touch input when you rotate the display. The touch +device needs its own coordinate transformation matrix. + +### Identify devices + +```bash +xrandr # output name — DSI-1, or None-1 on this unit +xinput list --name-only # look for Silead / GSL / SIS, NOT 'Touchpad' +``` + +> On this tablet the output reported as **`None-1`** (xrandr's fallback when the +> connector type isn't identified — it can change between kernels) and the +> touchscreen as **`SIS0457:00 0457:11ED`**. + +### Matrices + +| Rotation | Matrix | +|---|---| +| normal | `1 0 0 0 1 0 0 0 1` | +| right (90° CW) | `0 1 0 -1 0 1 0 0 1` | +| left (90° CCW) | `0 -1 1 1 0 0 0 0 1` | +| inverted (180°) | `-1 0 1 0 -1 1 0 0 1` | + +```bash +xrandr --output None-1 --rotate right +xinput set-prop "SIS0457:00 0457:11ED" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1 +``` + +### Persistence script + +```bash +nano ~/rotate.sh +``` + +```bash +#!/bin/bash +sleep 3 +OUT=$(xrandr | awk '/ connected/{print $1; exit}') +xrandr --output "$OUT" --rotate right +TS=$(xinput list --name-only | grep -iE 'silead|gsl|touchscreen|SIS' | grep -vi 'touchpad' | head -1) +[ -n "$TS" ] && xinput set-prop "$TS" "Coordinate Transformation Matrix" 0 1 0 -1 0 1 0 0 1 +xset s off +xset -dpms +``` + +```bash +chmod +x ~/rotate.sh +~/rotate.sh # test before automating +``` + +Then **Settings → Session and Startup → Application Autostart → Add**, command +`/home/ears/rotate.sh`. + +> **Gotcha:** grepping bare `touch` matches the **trackpad** and rotates the +> pointer instead. Use `touchscreen` and exclude `touchpad`. To undo: +> ```bash +> TP=$(xinput list --name-only | grep -i -m1 touchpad) +> xinput set-prop "$TP" "Coordinate Transformation Matrix" 1 0 0 0 1 0 0 0 1 +> ``` +> Or just log out — the matrix isn't saved anywhere. + +The `sleep 3` matters; the touchscreen isn't registered the instant the session +starts. Raise to 5 if touch still comes out wrong. + +**Try Settings → Display first.** Its rotation dropdown often persists on its +own, leaving the script to handle only the `xinput` part. + +### Login screen (LightDM — separate from your session) + +```bash +sudo nano /etc/lightdm/lightdm.conf +``` +```ini +[Seat:*] +display-setup-script=/usr/bin/xrandr --output None-1 --rotate right +``` + +### Better: rotate before the session starts + +```bash +sudo nano /etc/X11/xorg.conf.d/10-monitor.conf +``` +``` +Section "Monitor" + Identifier "DSI-1" + Option "Rotate" "right" +EndSection +``` + +Whole machine including the text console — add to `/etc/default/grub`: +`fbcon=rotate:1` plus `video=DSI-1:panel_orientation=right_side_up`, then +`sudo update-grub`. + +### Wallpaper breaks after rotating + +The desktop paints the wallpaper onto a surface sized for the old geometry and +doesn't repaint after `xrandr`. On Xfce: + +```bash +xfdesktop --reload +``` + +Also set the image style to **scaled** — `spanned` and `zoom` misbehave badly +when the aspect ratio flips. Rotating before the session starts avoids this +entirely. + +### Auto-rotation (optional) + +Xfce has no built-in handler. Confirm the sensor works before writing anything: + +```bash +sudo apt install -y iio-sensor-proxy +monitor-sensor # tilt the tablet; orientation should print +``` + +If nothing prints, this unit needs a kernel quirk — not worth chasing. + +```bash +monitor-sensor | while read -r line; do + case "$line" in + *normal*) R=normal; M="1 0 0 0 1 0 0 0 1" ;; + *right-up*) R=right; M="0 1 0 -1 0 1 0 0 1" ;; + *left-up*) R=left; M="0 -1 1 1 0 0 0 0 1" ;; + *bottom-up*) R=inverted; M="-1 0 1 0 -1 1 0 0 1" ;; + *) continue ;; + esac + xrandr --output DSI-1 --rotate "$R" + xinput set-prop "SIS0457:00 0457:11ED" "Coordinate Transformation Matrix" $M +done +``` + +> Most community auto-rotate scripts only call `xrandr` and leave touch behind. +> If a script doesn't contain `xinput set-prop`, it won't rotate touch. + +--- + +## 7. Audio + +### Install and test + +```bash +sudo apt install alsa-utils # this is what provides aplay + +aplay --version +aplay -l # list playback devices +aplay /usr/share/sounds/alsa/Front_Center.wav +speaker-test -c 2 -t wav -l 1 +``` + +PipeWire/PulseAudio layer: + +```bash +pactl info # "Server Name" tells you which +pactl list short sinks +paplay /usr/share/sounds/alsa/Front_Center.wav +``` + +### Choosing the right card + +Neither config file exists by default — create whichever you prefer: + +```bash +nano ~/.asoundrc # per-user; wins over the system file +# or +sudo nano /etc/asound.conf # system-wide, incl. root and systemd services +``` + +``` +defaults.pcm.card 1 +defaults.ctl.card 1 +``` + +`ctl` is what `alsamixer` reads, `pcm` is what `aplay` reads — set both. No +reload needed; it's read per-process. + +With `plughw` conversion behaviour as the default: + +``` +pcm.!default { + type plug + slave.pcm "hw:1,0" +} +ctl.!default { + type hw + card 1 +} +``` + +Verify — `-v` prints the resolved device: + +```bash +aplay -D default -v /usr/share/sounds/alsa/Front_Center.wav 2>&1 | head -20 +``` + +### Renumbering at the kernel level + +```bash +cat /proc/asound/modules +sudo nano /etc/modprobe.d/alsa-card-order.conf +``` +``` +options snd_soc_sst_bytcr_rt5640 index=0 +options snd_hda_intel index=1 +``` +```bash +sudo update-initramfs -u && sudo reboot +``` + +### Caveats + +- **PipeWire ignores all of the above.** `~/.asoundrc` only governs programs + talking to ALSA directly (`aplay`, `alsamixer`). For desktop apps use + `pactl set-default-sink` or `pavucontrol`. +- Save your mixer levels or they come back muted: `sudo alsactl store 1` +- Device busy → use `aplay -D default file.wav`, not `-D hw:0,0` +- Permission errors → `sudo usermod -aG audio $USER`, then re-login +- `aplay` handles WAV/AU/RAW only — use `mpg123` or `ffplay` for MP3/FLAC + +--- + +## 8. Running a script at boot + +```bash +sudo nano /usr/local/bin/myscript.sh +sudo chmod +x /usr/local/bin/myscript.sh +``` + +Start with `#!/bin/bash` and use **absolute paths** — there's almost no +environment at boot. + +```bash +sudo nano /etc/systemd/system/myscript.service +``` + +```ini +[Unit] +Description=My boot script +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/myscript.sh +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target +``` + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now myscript.service +systemctl status myscript.service +journalctl -u myscript.service +``` + +Variations: + +- Long-running daemon → `Type=simple`, drop `RemainAfterExit` +- Before the login screen → `Before=display-manager.service` in `[Unit]`, + `WantedBy=display-manager.service` in `[Install]` +- Quick and dirty → `sudo crontab -e`, then + `@reboot /usr/local/bin/myscript.sh` (no logging, no ordering) +- Drop `After=`/`Wants=` if networking isn't needed — it'll run earlier + +> **A root systemd service cannot rotate the display** — no `DISPLAY`, no +> `XAUTHORITY`. Use the config-level methods in §6 instead. Same for audio: a +> root service has no audio session, so use `systemctl --user` or target the +> hardware device directly. + +--- + +## 9. File transfer to/from Windows + +**Tailscale is not required.** It creates a private network *across the +internet* — only needed to reach the tablet from outside your home. For a +tablet and PC on the same Wi-Fi it's just another background daemon on a +2 GB machine. + +Lightest option — SSH: + +```bash +sudo apt install -y openssh-server +``` + +Connect from Windows with WinSCP or `scp` in PowerShell. No configuration +beyond the above. + +Samba is only worth it if you want the tablet's folders to appear in Windows +Explorer as a normal network drive. + +--- + +## 10. Quick reference + +```bash +# state +free -h; df -h /; lsblk; swapon --show +cat /etc/os-release +. /etc/os-release; echo $VERSION_CODENAME + +# apt health +sudo dpkg --audit; sudo apt-get check; apt-mark showhold +ls /etc/apt/sources.list.d/ +cat /etc/apt/sources.list.d/ubuntu.sources + +# display +xrandr; xinput list --name-only +~/rotate.sh; xfdesktop --reload + +# audio +aplay -l; pactl list short sinks +aplay /usr/share/sounds/alsa/Front_Center.wav +sudo alsactl store 1 + +# build +mcs -pkg:gtk-sharp-3.0 program.cs MainWindow.cs ArduinoConnection.cs -out:test.exe +mono test.exe +``` \ No newline at end of file diff --git a/Readme.md b/Readme.md index 402d96e..0c777af 100644 --- a/Readme.md +++ b/Readme.md @@ -1,20 +1,39 @@ # Mono + GTK# + Glade — WSL Setup Guide ## Table of Contents -- [1. Prerequisites](#1-prerequisites) -- [2. Install Mono](#2-install-mono) -- [3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)](#3-install-gtk-gtk-30-for-ubuntu-2404) -- [4. Install Glade (visual UI designer)](#4-install-glade-visual-ui-designer) -- [5. Optional: libgdiplus (only if using System.Drawing)](#5-optional-libgdiplus-only-if-using-systemdrawing) -- [6. Compiling & Running](#6-compiling--running) -- [7. Minimal Working Examples](#7-minimal-working-examples) -- [8. Custom Output Names, Targets & Exporting DLLs](#8-custom-output-names-targets--exporting-dlls) -- [9. Common Errors & Fixes](#9-common-errors--fixes) -- [10. Notes on Alternatives](#10-notes-on-alternatives) +- [Mono](#mono) + - [1. Prerequisites](#1-prerequisites) + - [2. Install Mono](#2-install-mono) + - [3. Install GTK# (GTK# 3.0, for Ubuntu 24.04)](#3-install-gtk-gtk-30-for-ubuntu-2404) + - [4. Install Glade (visual UI designer)](#4-install-glade-visual-ui-designer) + - [5. Optional: libgdiplus (only if using System.Drawing)](#5-optional-libgdiplus-only-if-using-systemdrawing) + - [6. Compiling & Running](#6-compiling--running) + - [7. Minimal Working Examples](#7-minimal-working-examples) + - [8. Custom Output Names, Targets & Exporting DLLs](#8-custom-output-names-targets--exporting-dlls) + - [9. Common Errors & Fixes](#9-common-errors--fixes) + - [10. Notes on Alternatives](#10-notes-on-alternatives) +- [OS](#os) + - [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) + --- -## 1. Prerequisites +## Mono + +### 1. Prerequisites - Windows 11 (or updated Windows 10) with WSL2 and **WSLg** enabled for GUI support - Ubuntu 24.04 (noble) or similar WSL distro @@ -26,7 +45,7 @@ --- -## 2. Install Mono +### 2. Install Mono ```bash sudo apt update @@ -41,7 +60,7 @@ --- -## 3. Install GTK# (GTK# 3.0, for Ubuntu 24.04) +### 3. Install GTK# (GTK# 3.0, for Ubuntu 24.04) > Note: `gtk-sharp2` is **not available** on Ubuntu 24.04 (noble). Use `gtk-sharp3` instead. @@ -59,7 +78,7 @@ gtk-sharp-3.0 Gtk - Gtk --- -## 4. Install Glade (visual UI designer) +### 4. Install Glade (visual UI designer) ```bash sudo apt install glade @@ -73,7 +92,7 @@ --- -## 5. Optional: libgdiplus (only if using System.Drawing) +### 5. Optional: libgdiplus (only if using System.Drawing) Only needed if your code uses `System.Drawing.Bitmap`, `Graphics`, etc. (not required for plain GTK#/Glade apps): @@ -83,21 +102,21 @@ --- -## 6. Compiling & Running +### 6. Compiling & Running -### Plain console C# program +#### Plain console C# program ```bash mcs myprogram.cs mono myprogram.exe ``` -### GTK# 3.0 program (no Glade) +#### GTK# 3.0 program (no Glade) ```bash mcs -pkg:gtk-sharp-3.0 myapp.cs mono myapp.exe ``` -### GTK# 3.0 program using a Glade file +#### GTK# 3.0 program using a Glade file ```bash mcs -pkg:gtk-sharp-3.0 -pkg:glade-sharp-3.0 myapp.cs mono myapp.exe @@ -106,9 +125,9 @@ --- -## 7. Minimal Working Examples +### 7. Minimal Working Examples -### Hello World (console) +#### Hello World (console) ```csharp using System; @@ -123,7 +142,7 @@ mono hello.exe ``` -### Hello World (GTK# window, no Glade) +#### Hello World (GTK# window, no Glade) ```csharp using System; using Gtk; @@ -149,7 +168,7 @@ mono gtkcheck.exe ``` -### Loading a UI built in Glade +#### Loading a UI built in Glade ```csharp using System; using Gtk; @@ -175,11 +194,11 @@ --- -## 8. Custom Output Names, Targets & Exporting DLLs +### 8. Custom Output Names, Targets & Exporting DLLs By default, `mcs file.cs` names the output after the source file (`file.exe`). You can control this with `-out:` and change what kind of binary is produced with `-target:`. -### 8.1 Custom output name +#### 8.1 Custom output name ```bash mcs -pkg:gtk-sharp-3.0 Program.cs -out:MyCustomApp.exe ``` @@ -188,7 +207,7 @@ mono MyCustomApp.exe ``` -### 8.2 `-target` options +#### 8.2 `-target` options | Target | Produces | Notes | |---|---|---| | `exe` (default) | Console executable | Shows a console window when run on Windows | @@ -202,7 +221,7 @@ ``` This produces `programwin.exe`, which on Windows will run without popping up a console window alongside your GTK window. -### 8.3 Exporting a DLL +#### 8.3 Exporting a DLL If you want to package reusable code (helper classes, business logic, etc.) as a library instead of a standalone app: @@ -213,7 +232,7 @@ - No `Main()` method is required in a `library` target (though it's fine if one class in your project has one — it just won't be used as an entry point for the DLL itself). - This creates `MyLibrary.dll`, a Mono/.NET assembly that other C# programs can reference. -### 8.4 Using a DLL in another program +#### 8.4 Using a DLL in another program Suppose `MyLibrary.dll` contains: ```csharp @@ -253,7 +272,7 @@ mono ConsumerApp.exe ``` -### 8.5 Combining `-target:library` with GTK# packages +#### 8.5 Combining `-target:library` with GTK# packages If your DLL itself uses GTK# types (e.g., a shared custom widget), include the package flag when building the library too: ```bash mcs -target:library -pkg:gtk-sharp-3.0 MyGtkWidgets.cs -out:MyGtkWidgets.dll @@ -263,7 +282,7 @@ mcs -pkg:gtk-sharp-3.0 ConsumerApp.cs -r:MyGtkWidgets.dll -out:ConsumerApp.exe ``` -### 8.6 Quick reference +#### 8.6 Quick reference ```bash # Console exe, custom name mcs Program.cs -out:myapp.exe @@ -278,7 +297,7 @@ mcs Consumer.cs -r:MyLibrary.dll -out:Consumer.exe ``` -### 8.7 Running the compiled .exe on Windows (outside WSL) +#### 8.7 Running the compiled .exe on Windows (outside WSL) Compiling in WSL produces a `.exe` that targets the .NET/Mono runtime — it is **not** a native Windows binary, and it will not run on Windows by itself. Two things are needed: @@ -308,7 +327,7 @@ --- -## 9. Common Errors & Fixes +### 9. Common Errors & Fixes | Error | Cause | Fix | |---|---|---| @@ -322,7 +341,7 @@ --- -## 10. Notes on Alternatives +### 10. Notes on Alternatives GTK# is a legacy, lightly-maintained binding. For new projects, consider: - **Avalonia UI** — modern, XAML-based, cross-platform, actively maintained @@ -353,4 +372,512 @@ to run note: same for all cases - mono NAME_OF_THE_FILE.exe \ No newline at end of file + mono NAME_OF_THE_FILE.exe + +## OS + +To test the application/setup across different Linux distributions, I used **Ventoy** to create a multiboot USB drive. Ventoy lets you copy multiple ISO files onto a single USB stick and choose which one to boot at startup, without needing to reformat or re-flash the drive for each OS. + +### Workflow +1. Install Ventoy on the target USB drive. +2. Copy the desired `.iso` files directly onto the Ventoy partition (no extraction needed). +3. Boot from the USB, select the ISO from the Ventoy boot menu, and test the OS live or install it. + +### OS images checked +- Fedora-Workstation-Live-44-1.7.x86_64 +- lubuntu-26.04-desktop-amd64 +- xubuntu-25.10-desktop-amd64 + +### OS images to check +- antiX-26_x64-full +- 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/ArduinoConnection.cs b/program/ArduinoConnection.cs new file mode 100644 index 0000000..6edc385 --- /dev/null +++ b/program/ArduinoConnection.cs @@ -0,0 +1,373 @@ +using System; +using System.IO.Ports; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +public class ArduinoConnection : IDisposable +{ + public const int BaudRate = 115200; + + // Must match what your sketch replies to "REQUEST_ID" (Form2.cs: EXPECTED_DEVICE_ID) + // public const string ExpectedDeviceId = "EARS_READER"; + public const string ExpectedDeviceId = "N_EARS_ESP32C3"; + + private const int HeartbeatTimeoutMs = 10000; + private const int HeartbeatCheckMs = 2000; + private const int MaxReconnectAttempts = 5; + private const int ResetSettleMs = 1500; // board reboots when DTR asserts + + private readonly object _gate = new object(); + private SerialPort _port; + private Thread _reader; + private volatile bool _reading; + + private DateTime _lastData = DateTime.MinValue; + private uint _heartbeatId; + private bool _autoReconnect; + private int _reconnectAttempts; + private DateTime _nextReconnect = DateTime.MinValue; + + /// Raised on the GTK main thread for every complete line from the device. + public event Action LineReceived; + /// Raised on the GTK main thread with human-readable status text. + public event Action Log; + /// Raised on the GTK main thread when the link goes up or down. + public event Action ConnectionChanged; + + public string PortName { get; private set; } + + public bool IsOpen + { + get { lock (_gate) return _port != null && _port.IsOpen; } + } + + public static string[] ListPorts() + { + return SerialPort.GetPortNames() + .Distinct() + .OrderBy(p => p, StringComparer.Ordinal) + .ToArray(); + } + + // ---------- public API ---------- + + public bool Connect(string portName) + { + if (string.IsNullOrEmpty(portName)) + { + Emit(Log, "No port selected"); + return false; + } + + ClosePort(silent: true); + + if (!OpenPort(portName)) + return false; + + _autoReconnect = false; + _reconnectAttempts = 0; + StartHeartbeat(); + + Emit(Log, $"Connected to {portName} @ {BaudRate}"); + Emit(ConnectionChanged, true); + + // Give the board time to finish booting before the first command. + Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3")); + return true; + } + + public void Disconnect() + { + _autoReconnect = false; + StopHeartbeat(); + ClosePort(silent: false); + } + + public void Send(string command) + { + try + { + lock (_gate) + { + if (_port == null || !_port.IsOpen) + { + Emit(Log, $"Cannot send '{command}' — not connected"); + return; + } + _port.Write(command + "\n"); + } + Emit(Log, $"Sent: {command}"); + } + catch (Exception ex) + { + Emit(Log, $"Send failed: {ex.Message}"); + } + } + + /// + /// Probes every port for a device answering REQUEST_ID with the expected ID. + /// Returns the port name, or null. Runs off the UI thread; the caller + /// connects on the main thread so no GLib call happens from a worker. + /// + public Task DetectPortAsync() + { + return Task.Run(() => + { + foreach (string p in ListPorts()) + { + if (IsOpen && p == PortName) continue; + + Emit(Log, $"Probing {p}..."); + string id; + if (Probe(p, out id)) + { + Emit(Log, $"Found {ExpectedDeviceId} on {p}"); + return p; + } + if (id != null) + Emit(Log, $" {p}: reported '{id}' (not a match)"); + } + return (string)null; + }); + } + + // ---------- port plumbing ---------- + + private bool OpenPort(string portName) + { + try + { + lock (_gate) + { + _port = new SerialPort(portName, BaudRate) + { + ReadTimeout = 500, + WriteTimeout = 1000, + NewLine = "\n", + DtrEnable = true, + RtsEnable = true, + }; + _port.Open(); + } + + PortName = portName; + _lastData = DateTime.Now; + StartReader(); + return true; + } + catch (Exception ex) + { + Emit(Log, $"Open failed on {portName}: {ex.Message}"); + lock (_gate) + { + _port?.Dispose(); + _port = null; + } + return false; + } + } + + private void ClosePort(bool silent) + { + _reading = false; + + Thread t = _reader; + _reader = null; + if (t != null && t.IsAlive && t != Thread.CurrentThread) + t.Join(1000); + + lock (_gate) + { + try { if (_port != null && _port.IsOpen) _port.Close(); } + catch (Exception ex) { Emit(Log, $"Close error: {ex.Message}"); } + _port?.Dispose(); + _port = null; + } + + if (!silent) + { + Emit(Log, "Disconnected"); + Emit(ConnectionChanged, false); + } + } + + private void StartReader() + { + _reading = true; + _reader = new Thread(ReaderLoop) + { + IsBackground = true, + Name = "arduino-reader" + }; + _reader.Start(); + } + + private void ReaderLoop() + { + var pending = new StringBuilder(); + var chunk = new byte[4096]; + + while (_reading) + { + SerialPort p; + lock (_gate) p = _port; + + if (p == null || !p.IsOpen) { Thread.Sleep(100); continue; } + + try + { + int n = p.Read(chunk, 0, chunk.Length); + if (n <= 0) continue; + + _lastData = DateTime.Now; + pending.Append(Encoding.ASCII.GetString(chunk, 0, n)); + + string buffered = pending.ToString(); + int nl; + while ((nl = buffered.IndexOf('\n')) >= 0) + { + string line = buffered.Substring(0, nl).Trim(); + buffered = buffered.Substring(nl + 1); + if (line.Length > 0) + Emit(LineReceived, line); + } + pending.Clear(); + pending.Append(buffered); + } + catch (TimeoutException) { /* normal when idle */ } + catch (Exception ex) + { + if (_reading) Emit(Log, $"Read error: {ex.Message}"); + Thread.Sleep(200); + } + } + } + + private bool Probe(string portName, out string deviceId) + { + deviceId = null; + SerialPort test = null; + try + { + test = new SerialPort(portName, BaudRate) + { + ReadTimeout = 500, + WriteTimeout = 1000, + NewLine = "\n", + DtrEnable = true, + RtsEnable = true, + }; + test.Open(); + Thread.Sleep(ResetSettleMs); + test.DiscardInBuffer(); + test.Write("REQUEST_ID\n"); + + DateTime deadline = DateTime.Now.AddSeconds(3); + while (DateTime.Now < deadline) + { + try + { + string line = test.ReadLine().Trim(); + if (line.StartsWith("DEVICE_ID:")) + { + deviceId = line.Substring("DEVICE_ID:".Length).Trim(); + return deviceId == ExpectedDeviceId; + } + } + catch (TimeoutException) { } + } + } + catch (Exception ex) + { + Emit(Log, $" {portName}: {ex.Message}"); + } + finally + { + try { test?.Close(); } catch { } + test?.Dispose(); + } + return false; + } + + // ---------- heartbeat / auto-reconnect (runs on the GTK main loop) ---------- + + private void StartHeartbeat() + { + StopHeartbeat(); + _heartbeatId = GLib.Timeout.Add(HeartbeatCheckMs, OnHeartbeatTick); + } + + private void StopHeartbeat() + { + if (_heartbeatId != 0) + { + GLib.Source.Remove(_heartbeatId); + _heartbeatId = 0; + } + } + + private bool OnHeartbeatTick() + { + if (_autoReconnect && !IsOpen) + { + if (_reconnectAttempts >= MaxReconnectAttempts) + { + _autoReconnect = false; + Log?.Invoke($"Reconnection failed after {MaxReconnectAttempts} attempts. Check USB cable, port and power, then reconnect manually."); + StopHeartbeat(); + return false; + } + + if (DateTime.Now >= _nextReconnect) + { + _reconnectAttempts++; + Log?.Invoke($"Reconnect attempt {_reconnectAttempts}/{MaxReconnectAttempts} on {PortName}..."); + + if (OpenPort(PortName)) + { + _autoReconnect = false; + _reconnectAttempts = 0; + Log?.Invoke("Reconnected"); + ConnectionChanged?.Invoke(true); + Task.Delay(ResetSettleMs).ContinueWith(_ => Send("MODE3")); + } + else + { + _nextReconnect = DateTime.Now.AddSeconds(2); + } + } + return true; + } + + if (!IsOpen) return true; + + if ((DateTime.Now - _lastData).TotalMilliseconds >= HeartbeatTimeoutMs) + { + Log?.Invoke("Heartbeat timeout — no device activity"); + ClosePort(silent: true); + ConnectionChanged?.Invoke(false); + _autoReconnect = true; + _reconnectAttempts = 0; + _nextReconnect = DateTime.Now.AddSeconds(2); + } + return true; + } + + // ---------- helpers ---------- + + private static void Emit(Action handler, T arg) + { + Action h = handler; + if (h == null) return; + Gtk.Application.Invoke((s, e) => h(arg)); + } + + public void Dispose() + { + _autoReconnect = false; + StopHeartbeat(); + ClosePort(silent: true); + } +} \ No newline at end of file diff --git a/program/CaseDefinition.cs b/program/CaseDefinition.cs new file mode 100644 index 0000000..39b9c85 --- /dev/null +++ b/program/CaseDefinition.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +/// One row of cases.csv. +public class CaseDefinition +{ + public int Number { get; set; } // Number + public string Type { get; set; } // Type 心音 / 呼吸音 + public string CategoryJp { get; set; } // Category + public string SubcategoryJp { get; set; } // Subcategory + public string LocationJp { get; set; } // Location + public string TreeLevel1 { get; set; } // Tree_Level1 + public string TreeLevel2 { get; set; } // Tree_Level2 + public string TreeLevel3 { get; set; } // Tree_Level3 + public string MapFront { get; set; } // Image_File + public string SoundPath { get; set; } // Sound_File + public string MapRight { get; set; } // Image_Right + public string MapLeft { get; set; } // Image_Left + public string MapBack { get; set; } // Image_Back + + public bool IsHeart + { + get { return !string.IsNullOrEmpty(Type) && Type.Contains("心"); } + } + + /// Non-empty levels only, e.g. 呼吸音 → 正常呼吸音 → 気管音. + /// Depth varies per row, which is what drives the drill-down. + public string[] TreePath + { + get + { + var parts = new List(); + foreach (string s in new[] { Type, TreeLevel1, TreeLevel2, TreeLevel3 }) + if (!string.IsNullOrWhiteSpace(s)) parts.Add(s.Trim()); + return parts.ToArray(); + } + } + + public override string ToString() + { + return Number + ": " + string.Join(" : ", TreePath); + } + + // ── loader ───────────────────────────────────────────────────────── + + public static List LoadCasesFromCsv(string path) + { + var list = new List(); + + if (!File.Exists(path)) + { + Console.WriteLine("CSV not found: " + path); + return list; + } + + // Encoding.UTF8 with BOM detection. Reading Japanese as the default + // ANSI codepage gives mojibake that only shows up on the buttons. + string[] lines; + try + { + lines = File.ReadAllLines(path, Encoding.UTF8); + } + catch (Exception ex) + { + Console.WriteLine("Failed to read " + path + ": " + ex.Message); + return list; + } + + if (lines.Length < 2) + { + Console.WriteLine("CSV has no data rows: " + path); + return list; + } + + // Your file is TAB separated; fall back to comma if it gets re-exported. + char sep = lines[0].Contains("\t") ? '\t' : ','; + + // Header name -> column index, so adding or reordering columns is safe. + // string[] header = lines[0].TrimStart('\uFEFF').Split(sep); + string[] header = lines[0].TrimStart(new[] { '\uFEFF' }).Split(sep); + var idx = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < header.Length; i++) + { + string key = header[i].Trim(); + if (key.Length > 0 && !idx.ContainsKey(key)) idx[key] = i; + } + + if (!idx.ContainsKey("Number")) + { + Console.WriteLine("CSV header has no 'Number' column — wrong file or wrong delimiter?"); + return list; + } + + Func get = (fields, name) => + { + int i; + if (!idx.TryGetValue(name, out i) || i >= fields.Length) return ""; + return fields[i].Trim(); + }; + + int skipped = 0; + + for (int r = 1; r < lines.Length; r++) + { + if (string.IsNullOrWhiteSpace(lines[r])) continue; + + string[] f = lines[r].Split(sep); + + int number; + if (!int.TryParse(get(f, "Number"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out number)) + { + skipped++; + Console.WriteLine("Line " + (r + 1) + ": bad Number, skipped"); + continue; + } + + list.Add(new CaseDefinition + { + Number = number, + Type = get(f, "Type"), + CategoryJp = get(f, "Category"), + SubcategoryJp = get(f, "Subcategory"), + LocationJp = get(f, "Location"), + TreeLevel1 = get(f, "Tree_Level1"), + TreeLevel2 = get(f, "Tree_Level2"), + TreeLevel3 = get(f, "Tree_Level3"), + MapFront = get(f, "Image_File"), + SoundPath = get(f, "Sound_File"), + MapRight = get(f, "Image_Right"), + MapLeft = get(f, "Image_Left"), + MapBack = get(f, "Image_Back") + }); + } + + Console.WriteLine("Loaded " + list.Count + " cases from " + path + + (skipped > 0 ? " (" + skipped + " skipped)" : "")); + return list; + } +} \ No newline at end of file diff --git a/program/JacketData.cs b/program/JacketData.cs new file mode 100644 index 0000000..fd9883c --- /dev/null +++ b/program/JacketData.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +/// One chip position. Mirrors Form2.Circle. +public class Circle +{ + public float Number { get; set; } + public int X { get; set; } // screen-space: OriginalX * 50 + MarginX + public int Y { get; set; } + public float OriginalX { get; set; } + public float OriginalY { get; set; } + public float OriginalZ { get; set; } + public bool Inner { get; set; } // non-integer Number +} + +/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL. +public class JacketData +{ + public const int CoordScale = 50; // Form2: originalX * 50 + public const int MarginX = 30; + public const int MarginY = 30; + + private const string ConfigFile = "config.txt"; + private const string DefaultDir = "JacketData"; + + private static readonly string[] Views = { "front", "back", "right", "left" }; + private static readonly string[] Sizes = { "L", "XL" }; + + private readonly Dictionary> _l = new Dictionary>(); + private readonly Dictionary> _xl = new Dictionary>(); + + public string Folder { get; private set; } + public bool Loaded { get; private set; } + + /// Form2.GetCurrentCSVData() + public List Get(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + List list; + return dict.TryGetValue(view, out list) ? list : new List(); + } + + public bool Has(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + return dict.ContainsKey(view) && dict[view].Count > 0; + } + + // ── folder resolution: config.txt, else JacketData/ next to the exe ── + + public static string ResolveFolder() + { + string cfg = SoundWindow.AppFile(ConfigFile); + + if (File.Exists(cfg)) + { + try + { + string saved = File.ReadAllText(cfg).Trim(); + if (Directory.Exists(saved) && Validate(saved)) + { + Logger.Write("jacket", "folder from config.txt: " + saved); + return saved; + } + + Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved); + } + catch (Exception ex) + { + Logger.Write("jacket", "config.txt unreadable: " + ex.Message); + } + } + + return SoundWindow.AppFile(DefaultDir); + } + + public static void SaveFolder(string folder) + { + try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); } + catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); } + } + + /// Form1.ValidateCSVFiles — all 8 must be present. + public static bool Validate(string folder) + { + foreach (string v in Views) + foreach (string s in Sizes) + if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv"))) + return false; + return true; + } + + public static string[] MissingFiles(string folder) + { + var missing = new List(); + foreach (string v in Views) + foreach (string s in Sizes) + { + string name = v + "_" + s + ".csv"; + if (!File.Exists(Path.Combine(folder, name))) missing.Add(name); + } + return missing.ToArray(); + } + + // ── loading ───────────────────────────────────────────────────────── + + public static JacketData Load() + { + return Load(ResolveFolder()); + } + + public static JacketData Load(string folder) + { + var data = new JacketData(); + data.Folder = folder; + + if (!Directory.Exists(folder)) + { + Logger.Write("jacket", "folder not found: " + folder); + return data; + } + + string[] missing = MissingFiles(folder); + if (missing.Length > 0) + Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing)); + + foreach (string view in Views) + { + foreach (string size in Sizes) + { + string path = Path.Combine(folder, view + "_" + size + ".csv"); + var circles = ReadCsv(path); + if (circles.Count == 0) continue; + + if (size == "XL") data._xl[view] = circles; + else data._l[view] = circles; + + Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv"); + } + } + + data.Loaded = (data._l.Count > 0 || data._xl.Count > 0); + if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder); + + return data; + } + + /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional. + private static List ReadCsv(string path) + { + var circles = new List(); + + if (!File.Exists(path)) + { + Logger.Write("jacket", "file not found: " + path); + return circles; + } + + string[] lines; + try { lines = File.ReadAllLines(path); } + catch (Exception ex) + { + Logger.Write("jacket", "read failed " + path + ": " + ex.Message); + return circles; + } + + int start = 0; + if (lines.Length > 0) + { + string h = lines[0].ToLower(); + if (h.Contains("number") || h.Contains("originalx")) start = 1; + } + + for (int i = start; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) continue; + + string[] p = line.Split(','); + if (p.Length < 4) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns"); + continue; + } + + float number, ox, oy, oz; + var inv = CultureInfo.InvariantCulture; + + // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse. + if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) || + !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) || + !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) || + !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz)) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format"); + continue; + } + + circles.Add(new Circle + { + Number = number, + X = (int)(ox * CoordScale) + MarginX, + Y = (int)(oy * CoordScale) + MarginY, + OriginalX = ox, + OriginalY = oy, + OriginalZ = oz, + Inner = (number % 1 != 0) + }); + } + + return circles; + } +} \ No newline at end of file diff --git a/program/Language.cs b/program/Language.cs new file mode 100644 index 0000000..a9afd86 --- /dev/null +++ b/program/Language.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Gtk; + +public class Language +{ + private const string EngCssPath = "lang_eng.css"; + private const string JpnCssPath = "lang_jpn.css"; + private const string EngCsvPath = "info_eng.csv"; + private const string JpnCsvPath = "info_jpn.csv"; + + private readonly Dictionary _engText; + private readonly Dictionary _jpnText; + private Dictionary _text; + + // widgets whose label is a plain CSV lookup + private readonly Dictionary _widgets = new Dictionary(); + + public readonly CssProvider _languageCssProvider = new CssProvider(); + + public bool IsEnglish { get; private set; } + + /// Raised after every language change, so callers can rebuild state-dependent text. + public event EventHandler Changed; + + public Language(bool isEnglish) + { + StyleContext.AddProviderForScreen(Gdk.Screen.Default, _languageCssProvider, 600); + + _engText = LoadText(EngCsvPath); + _jpnText = LoadText(JpnCsvPath); + + // set before any widget is built, so ctor-time lookups are already correct + IsEnglish = isEnglish; + _text = isEnglish ? _engText : _jpnText; + } + + // ---------- registration ---------- + + /// Label comes straight from the CSV key. Applied automatically on every change. + public void Register(string key, Widget widget) + { + if (widget == null) return; + widget.Name = key; // enables #Key selectors in the lang CSS + _widgets[key] = widget; + } + + /// Widget whose text the owner sets itself (state-dependent). Name only, no auto-apply. + public void RegisterDynamic(string key, Widget widget) + { + if (widget != null) widget.Name = key; + } + + // ---------- lookup ---------- + + public string this[string key] { get { return Get(key); } } + + public string Get(string key) + { + string value; + if (_text.TryGetValue(key, out value)) return value; + + Console.WriteLine("Warning: missing text key '" + key + "'"); + return key; + } + + public string Format(string key, params object[] args) + { + string template = Get(key); + try + { + return string.Format(CultureInfo.InvariantCulture, template, args); + } + catch (FormatException) + { + Console.WriteLine("Warning: bad placeholder in text key '" + key + "'"); + return template; + } + } + + // ---------- apply ---------- + + public void Apply(bool isEnglish) + { + IsEnglish = isEnglish; + _text = isEnglish ? _engText : _jpnText; + + foreach (var kvp in _widgets) + { + string value; + if (_text.TryGetValue(kvp.Key, out value)) + SetText(kvp.Value, value); + else + Console.WriteLine("Warning: missing text key '" + kvp.Key + "'"); + } + + string cssPath = isEnglish ? EngCssPath : JpnCssPath; + if (File.Exists(cssPath)) + _languageCssProvider.LoadFromPath(cssPath); + else + Console.WriteLine("Warning: CSS file not found: " + cssPath); + + EventHandler handler = Changed; + if (handler != null) handler(this, EventArgs.Empty); + } + + private static void SetText(Widget widget, string value) + { + Button button = widget as Button; // also covers ToggleButton + if (button != null) { button.Label = value; return; } + + Label label = widget as Label; + if (label != null) { label.Text = value; return; } + + Console.WriteLine("Warning: don't know how to set text on " + widget.GetType().Name); + } + + // ---------- csv ---------- + + private Dictionary LoadText(string path) + { + var result = new Dictionary(); + + if (!File.Exists(path)) + { + Console.WriteLine("Warning: text file not found: " + path); + return result; + } + + string[] lines = File.ReadAllLines(path); + for (int i = 1; i < lines.Length; i++) // row 0 is the header + { + string line = lines[i].Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith("#")) continue; + + string[] parts = line.Split(new[] { ',' }, 2); + if (parts.Length < 2) continue; + + result[parts[0].Trim()] = parts[1]; // value keeps any commas + } + + return result; + } +} \ No newline at end of file diff --git a/program/Logger.cs b/program/Logger.cs new file mode 100644 index 0000000..85b0efe --- /dev/null +++ b/program/Logger.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text; + +/// Append-only text log next to the .exe: logs/ears-YYYYMMDD.log +public static class Logger +{ + private static readonly object _gate = new object(); + private static string _path; + private static bool _failed; + + public static void Write(string message) + { + Write(null, message); + } + + public static void Write(string tag, string message) + { + if (_failed) return; + + string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + + (string.IsNullOrEmpty(tag) ? " " : " [" + tag + "] ") + + message; + + lock (_gate) + { + try + { + if (_path == null) _path = Init(); + File.AppendAllText(_path, line + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) + { + _failed = true; // never let logging crash the app + Console.WriteLine("[Logger] disabled: " + ex.Message); + } + } + } + + public static void Write(string tag, string format, params object[] args) + { + Write(tag, string.Format(format, args)); + } + + public static void Exception(string tag, Exception ex) + { + Write(tag, ex.GetType().Name + ": " + ex.Message); + Write(tag, ex.StackTrace ?? "(no stack)"); + } + + private static string Init() + { + string dir = Path.Combine( + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), + "logs"); + + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, "ears-" + DateTime.Now.ToString("yyyyMMdd") + ".log"); + + File.AppendAllText(path, + Environment.NewLine + + "=== session start " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + + " (" + Environment.OSVersion.Platform + ") ===" + Environment.NewLine, + Encoding.UTF8); + + return path; + } +} \ No newline at end of file diff --git a/program/MainWindow.cs b/program/MainWindow.cs new file mode 100644 index 0000000..9a2b13a --- /dev/null +++ b/program/MainWindow.cs @@ -0,0 +1,349 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; +using System.Runtime.InteropServices; + +public class MainWindow : Window +{ + + // ---- stack + pages (new all-in-one glade) ---- + [UI] private Notebook RootNoteBook = null; + + private const int PageSplash = 0; + private const int PageSettings = 1; + private const int PageSound = 2; + private const int PageMap = 3; + + // ---- splash page widgets ---- + [UI] private Button Option1Button = null; + [UI] private Button Option2Button = null; + [UI] private Button Option3Button = null; + // [UI] private Switch LanguageSwitch = null; + [UI] private ToggleButton LanguageToggle = null; + [UI] private Label LanguageLabel = null; + [UI] private Button CloseButton = null; + [UI] private Button SettingButton = null; + [UI] private Image ConnectionIcon = null; // was GtkIconView in the old glade + + private CssProvider _scaleCssProvider; + // private CssProvider _languageCssProvider; + private int _lastAppliedWidth = -1; + + private const int BaseWidth = 1920; + private const int BaseHeight = 1080; + + // private const string EngCssPath = "lang_eng.css"; + // private const string JpnCssPath = "lang_jpn.css"; + + // private Dictionary _engText; + // private Dictionary _jpnText; + // private Dictionary _buttonsById; + private bool _connected; + + private readonly ArduinoConnection _arduino = new ArduinoConnection(); + internal SettingsWindow _settingsWindow; + internal Language _Language; + + internal SoundWindow _soundWindow; + internal MapWindow _mapWindow; + + public MainWindow() : this(CreateBuilder()) { } + + private static Builder CreateBuilder() + { + var builder = new Builder(); + builder.AddFromFile("homepageallv6.glade"); + return builder; + } + + private MainWindow(Builder builder) : base(builder.GetObject("Root").Handle) + { + builder.Autoconnect(this); + + _scaleCssProvider = new CssProvider(); + // _languageCssProvider = new CssProvider(); + + StyleContext.AddProviderForScreen(Gdk.Screen.Default, _scaleCssProvider, 600); + // StyleContext.AddProviderForScreen(Gdk.Screen.Default, _Language._languageCssProvider, 600); + + // make it full screen + this.Fullscreen(); + // //use the linux computer resolution + // this.SetDefaultSize(1280, 800); + // this.SetPosition(WindowPosition.Center); + + // RootStack.TransitionType = StackTransitionType.None; + // RootStack.TransitionType = StackTransitionType.SlideLeftRight; + // RootStack.TransitionDuration = 150; + + Option1Button.Clicked += OnNormalSessionClicked; + Option2Button.Clicked += OnPositionRecordingClicked; + Option3Button.Clicked += OnExamModeClicked; + CloseButton.Clicked += OnCloseClicked; + SettingButton.Clicked += OnSettingClicked; + // LanguageSwitch.AddNotification("active", OnLanguageSwitchNotify); + LanguageToggle.Toggled += OnLanguageToggled; + + DeleteEvent += OnDeleteEvent; + SizeAllocated += OnWindowSizeAllocated; + + _arduino.ConnectionChanged += OnArduinoConnectionChanged; + + + // // _buttonsById = new Dictionary + // // { + // // { "Option1Button", Option1Button }, + // // { "Option2Button", Option2Button }, + // // { "Option3Button", Option3Button }, + // // { "CloseButton", CloseButton }, + // // { "SettingButton", SettingButton }, + // // }; + + // // CSS ID selectors (#Option1Button etc.) need the widget Name set explicitly + // foreach (var kvp in _buttonsById) + // kvp.Value.Name = kvp.Key; + + // // _engText = LoadButtonText("info_eng.csv"); + // // _jpnText = LoadButtonText("info_jpn.csv"); + + // _Language = new Language(_buttonsById,LanguageLabel); + + // // _Language.ApplyLanguage(LanguageSwitch.Active); + + _Language = new Language(LanguageToggle.Active); + _Language.Register("Option1Button", Option1Button); + _Language.Register("Option2Button", Option2Button); + _Language.Register("Option3Button", Option3Button); + _Language.Register("CloseButton", CloseButton); + _Language.Register("SettingButton", SettingButton); + _Language.Register("LanguageLabel", LanguageLabel); + _Language.Register("LanguageToggle", LanguageToggle); + _Language.RegisterDynamic("ConnectionIcon", ConnectionIcon); + + _settingsWindow = new SettingsWindow(builder, _arduino, _Language); + _settingsWindow.BackRequested += OnSettingsClosed; + + _mapWindow = new MapWindow(builder, _Language); + _mapWindow.BackRequested += OnMapClosed; + + _Language.Changed += (s, e) => OnArduinoConnectionChanged(_connected); + _Language.Apply(LanguageToggle.Active); + + // // single source of truth: the toggle drives both pages + // ApplyLanguageEverywhere(LanguageToggle.Active); + + RootNoteBook.ShowTabs = false; + RootNoteBook.ShowBorder = false; + RootNoteBook.CurrentPage = PageSplash; + _soundWindow = new SoundWindow(builder); + _soundWindow.BackRequested += (s, e) => RootNoteBook.CurrentPage = PageSplash; + _soundWindow.CaseSelected += OnCaseSelected; + + + OnArduinoConnectionChanged(false); + } + + // ---------- settings panel ---------- + + private void OnSettingClicked(object sender, EventArgs e) + { + // if (_settingsWindow == null) + // { + // // _settingsWindow = new SettingsWindow(_arduino); + // // _settingsWindow.BackRequested += OnSettingsClosed; + // // _settingsWindow.ShowAll(); // build it fullscreen once, up front + // // _settingsWindow.Fullscreen(); + // RootNoteBook.CurrentPage = PageSettings; + // } + + + // _settingsWindow.ShowAll(); + // _settingsWindow.Present(); + // this.Hide(); + RootNoteBook.CurrentPage = PageSettings; + } + + private void OnSettingsClosed(object sender, EventArgs e) + { + // this.ShowAll(); + // this.Present(); + // _settingsWindow.Hide(); + RootNoteBook.CurrentPage = PageSplash; + } + + private void OnArduinoConnectionChanged(bool connected) + { + _connected = connected; + if (ConnectionIcon == null) return; + + ConnectionIcon.SetFromIconName( + connected ? "network-transmit-receive" : "network-offline", + IconSize.Dnd); + + ConnectionIcon.TooltipText = connected + ? _Language.Format("ConnectionIcon.Connected", _arduino.PortName) + : _Language["ConnectionIcon.Disconnected"]; + } + + // ---------- localisation ---------- + + // private Dictionary LoadButtonText(string path) + // { + // var result = new Dictionary(); + + // if (!File.Exists(path)) + // { + // Console.WriteLine($"Warning: text file not found: {path}"); + // return result; + // } + + // var lines = File.ReadAllLines(path); + // for (int i = 1; i < lines.Length; i++) // skip header row + // { + // var line = lines[i].Trim(); + // if (string.IsNullOrEmpty(line)) continue; + + // var parts = line.Split(new[] { ',' }, 2); + // if (parts.Length < 2) continue; + + // result[parts[0]] = parts[1]; + // } + + // return result; + // } + + // private void ApplyLanguage(bool isEnglish) + // { + // var text = isEnglish ? _engText : _jpnText; + // string cssPath = isEnglish ? EngCssPath : JpnCssPath; + + // foreach (var kvp in _buttonsById) + // { + // if (text.TryGetValue(kvp.Key, out var buttonText)) + // kvp.Value.Label = buttonText; + // } + + // if (File.Exists(cssPath)) + // _languageCssProvider.LoadFromPath(cssPath); + // else + // Console.WriteLine($"Warning: CSS file not found: {cssPath}"); + + // LanguageLabel.Text = isEnglish ? "あ" : "A"; + // } + + // ---------- scaling ---------- + + private void OnWindowSizeAllocated(object o, SizeAllocatedArgs args) + { + int width = args.Allocation.Width; + int height = args.Allocation.Height; + + if (width == _lastAppliedWidth) return; + _lastAppliedWidth = width; + + double scaleX = (double)width / BaseWidth; + double scaleY = (double)height / BaseHeight; + + ApplyScale(Math.Min(scaleX, scaleY)); + } + + private void ApplyScale(double scale) + { + int fontSize = Math.Max(10, (int)(20 * scale)); + int buttonPadV = Math.Max(4, (int)(12 * scale)); + int buttonPadH = Math.Max(8, (int)(24 * scale)); + int switchWidth = Math.Max(30, (int)(40 * scale)); + int switchHeight= Math.Max(18, (int)(24 * scale)); + + // NOTE: integers + InvariantCulture. Interpolating a double here emitted + // "40,5px" under ja_JP / de_DE locales and GTK silently dropped the rule. + string css = string.Format(CultureInfo.InvariantCulture, @" + grid, label, button, switch {{ + font-size: {0}px; + }} + button {{ + padding: {1}px {2}px; + }} + switch {{ + min-width: {3}px; + min-height: {4}px; + }} + ", fontSize, buttonPadV, buttonPadH, switchWidth, switchHeight); + + _scaleCssProvider.LoadFromData(css); + } + + // ---------- lifecycle ---------- + + private void OnDeleteEvent(object sender, DeleteEventArgs a) + { + if (_mapWindow != null) _mapWindow.StopSession(); + Application.Quit(); + a.RetVal = true; + // Shutdown(); + // a.RetVal = true; + } + + private void OnCloseClicked(object sender, EventArgs e) + { + Shutdown(); + } + + private void Shutdown() + { + _arduino.Disconnect(); + _arduino.Dispose(); + // _settingsWindow?.Destroy(); + Application.Quit(); + } + + private void OnNormalSessionClicked(object sender, EventArgs e) + { + Console.WriteLine("Normal Session selected"); + RootNoteBook.CurrentPage = PageSound; + _soundWindow.Reset(); + } + + private void OnPositionRecordingClicked(object sender, EventArgs e) + { + Console.WriteLine("Position Recording Mode selected"); + } + + private void OnExamModeClicked(object sender, EventArgs e) + { + Console.WriteLine("Exam Mode selected"); + } + + private void OnLanguageToggled(object sender, EventArgs e) + { + _Language.Apply(LanguageToggle.Active); + } + + // private void ApplyLanguageEverywhere(bool isEnglish) + // { + // Language.LanguageSwitchValue = isEnglish; + // _Language.ApplyLanguage(isEnglish); + // _settingsWindow?._Language?.ApplyLanguages(isEnglish); + // } + + private void OnCaseSelected(object sender, CaseDefinition def) + { + // _mapWindow.ShowCase( + // string.Join(" : ", def.TreePath), // no DisplayName on CaseDefinition + // SoundWindow.AppFile("body/front.png"), + // SoundWindow.AppFile(def.MapFront), + // SoundWindow.AppFile(def.SoundPath)); + + // RootNoteBook.CurrentPage = PageMap; + _mapWindow.ShowCase(def, string.Join(" : ", def.TreePath)); + RootNoteBook.CurrentPage = PageMap; + } + + private void OnMapClosed(object sender, EventArgs e) + { + RootNoteBook.CurrentPage = PageSound; // back to disease list + } +} \ No newline at end of file diff --git a/program/MapWindow.cs b/program/MapWindow.cs new file mode 100644 index 0000000..b3915ed --- /dev/null +++ b/program/MapWindow.cs @@ -0,0 +1,454 @@ +using System; +using System.IO; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +public class MapWindow : IDisposable +{ + // Form2 view offsets — L size + private const float FRONT_OFFSET_X = 25.0f, FRONT_OFFSET_Y = 50.0f; + private const float BACK_OFFSET_X = 20.0f, BACK_OFFSET_Y = 20.0f; + private const float RIGHT_OFFSET_X = 10.0f, RIGHT_OFFSET_Y = -150.0f; + private const float LEFT_OFFSET_X = -50.0f, LEFT_OFFSET_Y = -150.0f; + + private const float FRONT_SIZE = 1.0f, BACK_SIZE = 0.5f, + RIGHT_SIZE = 1.0f, LEFT_SIZE = 1.0f; + + // XL deltas + private const float XL_FRONT_OFFSET_X = -28.0f, XL_FRONT_OFFSET_Y = 24.0f; + private const float XL_BACK_OFFSET_X = 8.0f, XL_BACK_OFFSET_Y = -15.0f; + private const float XL_RIGHT_OFFSET_X = -41.0f, XL_RIGHT_OFFSET_Y = 8.0f; + private const float XL_LEFT_OFFSET_X = 54.0f, XL_LEFT_OFFSET_Y = 0.0f; + + private const int CircleDiameter = 50; + // ── glade widgets ── + [UI] private DrawingArea MapArea = null; + [UI] private Label MapTitleLabel = null; + [UI] private Label MapCaseLabel = null; + [UI] private Button MapBackButton = null; + [UI] private RadioButton BodySkeletonRadio = null; + [UI] private RadioButton BodyJacketRadio = null; + [UI] private ComboBoxText JacketSizeCombo = null; + [UI] private RadioButton ViewFrontRadio = null; + [UI] private RadioButton ViewBackRadio = null; + [UI] private RadioButton ViewRightRadio = null; + [UI] private RadioButton ViewLeftRadio = null; + [UI] private CheckButton HideCircleCheck = null; + + private readonly Language _lang; + private readonly SoundLooper _sound = new SoundLooper(); + + // ── state ── + private Gdk.Pixbuf _body; // background, native size + private Gdk.Pixbuf _map; // 546x546, black keyed out — also the audio reference + private CaseDefinition _case; + private string _view = "front"; + + private const int MapSize = 546; // Form2: new Bitmap(mapImage, 546, 546) + private const double MapAlpha = 0.7; // Form2: CreateOverlayImage(..., 0.7f, ...) + + public event EventHandler BackRequested; + + private JacketData _jacket; + private System.Collections.Generic.List _highlighted = new System.Collections.Generic.List(); + private static bool _listed; + + public MapWindow(Builder builder, Language language) + { + builder.Autoconnect(this); + + _jacket = JacketData.Load(); + Logger.Write("jacket", "folder: " + _jacket.Folder + " loaded=" + _jacket.Loaded); + + if (MapArea == null || MapTitleLabel == null || MapBackButton == null) + throw new InvalidOperationException( + "Glade id mismatch — MapArea=" + (MapArea != null) + + " MapTitleLabel=" + (MapTitleLabel != null) + + " MapBackButton=" + (MapBackButton != null)); + + _lang = language; + + MapArea.Drawn += OnMapDrawn; + MapBackButton.Clicked += OnBackClicked; + + BodySkeletonRadio.Toggled += (s, e) => { if (BodySkeletonRadio.Active) ReloadBody(); }; + BodyJacketRadio.Toggled += (s, e) => { if (BodyJacketRadio.Active) ReloadBody(); }; + JacketSizeCombo.Changed += (s, e) => { ReloadBody(); MapArea.QueueDraw(); }; + + HookView(ViewFrontRadio, "front"); + HookView(ViewBackRadio, "back"); + HookView(ViewRightRadio, "right"); + HookView(ViewLeftRadio, "left"); + + HideCircleCheck.Toggled += (s, e) => MapArea.QueueDraw(); + + _sound.Log += msg => Logger.Write("sound", msg); + } + + private void HookView(RadioButton rb, string view) + { + if (rb == null) return; + rb.Toggled += (s, e) => { if (rb.Active) SetView(view); }; + } + + // ── public API ───────────────────────────────────────────────────── + + public void ShowCase(CaseDefinition def, string title) + { + _case = def; + _view = "front"; + if (ViewFrontRadio != null) ViewFrontRadio.Active = true; + + MapTitleLabel.Text = title ?? ""; + MapCaseLabel.Text = title ?? "-"; + + DisposePixbufs(); + _body = LoadBody(); + _map = LoadMap(MapPathForView()); + + MapArea.QueueDraw(); + _sound.Start(SoundWindow.AppFile(def.SoundPath)); + + Logger.Write("case", "open: " + title + " | map=" + MapPathForView()); + } + + public void StopSession() + { + _sound.Stop(); + Logger.Write("case", "closed"); + } + + /// Raw MAP pixbuf — Form2's originalMapForAudio. Never composited. + public Gdk.Pixbuf AudioReference { get { return _map; } } + + // ── the paint ────────────────────────────────────────────────────── + + private void OnMapDrawn(object o, DrawnArgs args) + { + var cr = args.Cr; + int w = MapArea.AllocatedWidth; + int h = MapArea.AllocatedHeight; + + if (_body == null) { args.RetVal = true; return; } + + // ONE scale factor, derived from the body — this is what keeps the + // overlay registered. Zoom-to-fit, same as PictureBoxSizeMode.Zoom. + double s = Math.Min((double)w / _body.Width, (double)h / _body.Height); + double ox = (w - _body.Width * s) / 2.0; + double oy = (h - _body.Height * s) / 2.0; + + cr.Save(); + cr.Translate(ox, oy); + cr.Scale(s, s); // everything below is in BODY PIXELS + + Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0); + cr.Paint(); + + if (_map != null) + { + // CreateOverlayImage(position: null) == centre the MAP on the body + double mx = (_body.Width - _map.Width) / 2.0; + double my = (_body.Height - _map.Height) / 2.0; + + Gdk.CairoHelper.SetSourcePixbuf(cr, _map, mx, my); + cr.PaintWithAlpha(MapAlpha); + } + + if (!HideCircleCheck.Active) + DrawCircles(cr); // body-pixel coords; transform does the rest + + cr.Restore(); + args.RetVal = true; + } + + private void DrawCircles(Cairo.Context cr) + { + if (_jacket == null || _body == null) return; + + var circles = _jacket.Get(JacketSize, _view); + if (circles.Count == 0) return; + + // "panel" is the body pixbuf — Form2's gridPictureBox was 546x546 + int panelW = _body.Width; + int panelH = _body.Height; + + float scale = ScalingFactor(circles, panelW, panelH); + + float minX = float.MaxValue, maxX = float.MinValue; + float minY = float.MaxValue, maxY = float.MinValue; + foreach (var c in circles) + { + if (c.X < minX) minX = c.X; + if (c.X > maxX) maxX = c.X; + if (c.Y < minY) minY = c.Y; + if (c.Y > maxY) maxY = c.Y; + } + + float offsetX = (panelW - (maxX - minX) * scale) / 2 - minX * scale; + float offsetY = (panelH - (maxY - minY) * scale) / 2 - minY * scale; + + cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold); + + foreach (var c in circles) + DrawOneCircle(cr, c, scale, offsetX, offsetY); + } + + private float ScalingFactor(System.Collections.Generic.List circles, int panelW, int panelH) + { + if (circles.Count == 0) return 1.0f; + + int availW = panelW - 40, availH = panelH - 40; + if (_view == "front") { availW = panelW - 100; availH = panelH - 100; } + + float minX = float.MaxValue, maxX = float.MinValue; + float minY = float.MaxValue, maxY = float.MinValue; + foreach (var c in circles) + { + if (c.X < minX) minX = c.X; + if (c.X > maxX) maxX = c.X; + if (c.Y < minY) minY = c.Y; + if (c.Y > maxY) maxY = c.Y; + } + + const float circleRadius = 2.1f / 2; + float dataW = (maxX - minX) + circleRadius * 2; + float dataH = (maxY - minY) + circleRadius * 2; + + if (dataW <= 0 || dataH <= 0) return 1.0f; + + return Math.Min(availW / dataW, availH / dataH) * 0.9f; + } + + private void DrawOneCircle(Cairo.Context cr, Circle c, float scale, float ox, float oy) + { + float x, y, sizeScale; + + switch (_view) + { + case "back": + x = c.X * scale + ox - BACK_OFFSET_X; + y = c.Y * scale + oy - BACK_OFFSET_Y; + sizeScale = BACK_SIZE; + break; + case "right": + x = c.X * scale + ox - RIGHT_OFFSET_X; + y = c.Y * scale + oy - RIGHT_OFFSET_Y; + sizeScale = RIGHT_SIZE; + break; + case "left": + x = c.X * scale + ox - LEFT_OFFSET_X; + y = c.Y * scale + oy - LEFT_OFFSET_Y; + sizeScale = LEFT_SIZE; + break; + default: + x = c.X * scale + ox - FRONT_OFFSET_X; + y = c.Y * scale + oy - FRONT_OFFSET_Y; + sizeScale = FRONT_SIZE; + break; + } + + if (JacketSize == "XL") + { + switch (_view) + { + case "back": x += XL_BACK_OFFSET_X; y += XL_BACK_OFFSET_Y; break; + case "right": x += XL_RIGHT_OFFSET_X; y += XL_RIGHT_OFFSET_Y; break; + case "left": x += XL_LEFT_OFFSET_X; y += XL_LEFT_OFFSET_Y; break; + default: x += XL_FRONT_OFFSET_X; y += XL_FRONT_OFFSET_Y; break; + } + } + + double d = CircleDiameter * scale * sizeScale; + double r = d / 2; + + bool hot = false; + foreach (float n in _highlighted) + if (Math.Abs(c.Number - n) < 0.001f) { hot = true; break; } + + cr.NewPath(); + cr.Arc(x, y, r, 0, 2 * Math.PI); + + if (hot) + { + // cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); // Color.FromArgb(255,235,59) + // cr.Arc(x, y, r, 0, 2 * Math.PI); + // cr.FillPreserve(); + cr.SetSourceRGBA(1.0, 0.922, 0.231, 1.0); + cr.FillPreserve(); + } + // else + // { + // cr.Arc(x, y, r, 0, 2 * Math.PI); + // } + + cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); // BorderColor = Gray + cr.LineWidth = Math.Max(1.0, scale); + cr.Stroke(); + + string text = (c.Number % 1 == 0) + ? ((int)c.Number).ToString() + : c.Number.ToString("F1"); + + cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4))); + var ext = cr.TextExtents(text); + cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2); + cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0); // TextColor + cr.ShowText(text); + cr.NewPath(); + } + + // ── loading ──────────────────────────────────────────────────────── + + private void SetView(string view) + { + if (_view == view) return; + _view = view; + + ReloadBody(); + + if (_map != null) { _map.Dispose(); _map = null; } + _map = LoadMap(MapPathForView()); + + MapArea.QueueDraw(); + Logger.Write("view", "-> " + view); + } + + private void ReloadBody() + { + if (_body != null) { _body.Dispose(); _body = null; } + _body = LoadBody(); + MapArea.QueueDraw(); + } + + private Gdk.Pixbuf LoadBody() + { + string stem = BodyStem(); + string full = SoundWindow.AppFile(Path.Combine("map", stem + ".png")); + + if (!File.Exists(full)) + { + Logger.Write("body", "not found: map/" + stem + ".png"); + ListFolderOnce("map"); + return null; + } + + return Load(full); + // string type = (BodyJacketRadio != null && BodyJacketRadio.Active) ? "jacket" : "skel"; + // string size = (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; + + // // Adjust to your actual body/ filenames. + // string rel = Path.Combine("body", type + "_" + _view + "_" + size + ".png"); + // return Load(SoundWindow.AppFile(rel)); + } + private static void ListFolderOnce(string rel) + { + if (_listed) return; + _listed = true; + + string dir = SoundWindow.AppFile(rel); + if (!Directory.Exists(dir)) { Logger.Write("body", "folder missing: " + dir); return; } + + foreach (string f in Directory.GetFiles(dir)) + Logger.Write("body", "found: " + Path.GetFileName(f)); + } + + private string BodyStem() + { + bool skeleton = (BodySkeletonRadio != null && BodySkeletonRadio.Active); + + if (skeleton) + { + switch (_view) + { + case "back": return "jacketBackBody"; + case "right": return "jacketRightBody"; + case "left": return "jacketLeftBody"; + default: return "jacketFrontBody"; + } + } + + switch (_view) + { + case "back": return "jacketBack"; + case "right": return "jacketRight"; + case "left": return "jacketLeft"; + default: return "jacketFront"; + } + } + + private string MapPathForView() + { + if (_case == null) return null; + + string rel; + switch (_view) + { + case "back": rel = _case.MapBack; break; + case "right": rel = _case.MapRight; break; + case "left": rel = _case.MapLeft; break; + default: rel = _case.MapFront; break; + } + + // Form2 falls back to a mirrored front map when there's no dedicated back. + if (string.IsNullOrEmpty(rel)) rel = _case.MapFront; + + return string.IsNullOrEmpty(rel) ? null : SoundWindow.AppFile(rel); + } + + private static Gdk.Pixbuf Load(string path) + { + if (string.IsNullOrEmpty(path) || !File.Exists(path)) + { + Logger.Write("map", "image not found: " + path); + return null; + } + + try { return new Gdk.Pixbuf(path); } + catch (Exception ex) + { + Logger.Write("map", "load failed " + path + ": " + ex.Message); + return null; + } + } + + private static Gdk.Pixbuf LoadMap(string path) + { + var raw = Load(path); + if (raw == null) return null; + + // Key out black FIRST, then scale. Scaling first leaves near-black + // edge pixels (1,0,2) that AddAlpha won't match — that's the dark halo. + var keyed = raw.AddAlpha(true, 0, 0, 0); + raw.Dispose(); + + var sized = keyed.ScaleSimple(MapSize, MapSize, Gdk.InterpType.Bilinear); + keyed.Dispose(); + return sized; + } + + // ── plumbing ─────────────────────────────────────────────────────── + + private void OnBackClicked(object sender, EventArgs e) + { + StopSession(); + var h = BackRequested; + if (h != null) h(this, EventArgs.Empty); + } + + private void DisposePixbufs() + { + if (_body != null) { _body.Dispose(); _body = null; } + if (_map != null) { _map.Dispose(); _map = null; } + } + + public void Dispose() + { + _sound.Dispose(); + DisposePixbufs(); + } + + private string JacketSize + { + get { return (JacketSizeCombo != null ? JacketSizeCombo.ActiveText : null) ?? "L"; } + } +} \ No newline at end of file diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs new file mode 100644 index 0000000..f091c74 --- /dev/null +++ b/program/SettingWindow.cs @@ -0,0 +1,217 @@ +using System; +using Gtk; +using System.Collections.Generic; +using UI = Gtk.Builder.ObjectAttribute; + +public class SettingsWindow +{ + [UI] private Label ComPortLabel = null; + [UI] private ComboBoxText ComPortComboBox = null; + [UI] private Button ConnectButton = null; + [UI] private Button AutoConnectButton = null; + [UI] private Button RefreshButton = null; + [UI] private Button SettingBackButton = null; + [UI] private Label StatusLabel = null; + [UI] private Label LogLabel = null; + [UI] private TextView LogtextBox = null; + + // private readonly Dictionary _buttonsById; + // private readonly Dictionary _labelsById; + // internal Language _Language; + private readonly Language _lang; + private bool _connected; + + private const int MaxLogLines = 500; + + private readonly ArduinoConnection _arduino; + private string[] _ports = new string[0]; + + /// Raised after the window hides itself, so the caller can re-show the main window. + public event EventHandler BackRequested; + + + public SettingsWindow(Builder builder, ArduinoConnection arduino, Language language) + { + builder.Autoconnect(this); + _arduino = arduino; + _lang = language; + + // Title = "Settings"; + // ComPortLabel.Text = "COM Port"; + // LogLabel.Text = "Log"; + LogtextBox.Editable = false; + LogtextBox.WrapMode = WrapMode.WordChar; + + RefreshButton.Clicked += (s, e) => RefreshPorts(); + ConnectButton.Clicked += OnConnectClicked; + AutoConnectButton.Clicked += OnAutoConnectClicked; + SettingBackButton.Clicked += OnBackClicked; + + // DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); }; + + _arduino.Log += AppendLog; + _arduino.LineReceived += OnLineReceived; + _arduino.ConnectionChanged += OnConnectionChanged; + + _lang.Register("ComPortLabel", ComPortLabel); + _lang.Register("LogLabel", LogLabel); + _lang.Register("AutoConnectButton", AutoConnectButton); + _lang.Register("RefreshButton", RefreshButton); + _lang.Register("SettingBackButton", SettingBackButton); + _lang.RegisterDynamic("ConnectButton", ConnectButton); // set in OnConnectionChanged + _lang.RegisterDynamic("StatusLabel", StatusLabel); + _lang.Changed += (s, e) => OnConnectionChanged(_connected); + + // _buttonsById = new Dictionary + // { + // { "ConnectButton", ConnectButton }, + // { "AutoConnectButton", AutoConnectButton }, + // { "RefreshButton", RefreshButton }, + // { "BackButton", BackButton }, + // }; + + // // CSS ID selectors (#Option1Button etc.) need the widget Name set explicitly + // foreach (var kvp in _buttonsById) + // kvp.Value.Name = kvp.Key; + + // _Language = new Language(_buttonsById); + // // _Language.ApplyLanguages(Language.LanguageSwitchValue); + + RefreshPorts(); + OnConnectionChanged(_arduino.IsOpen); + } + + // ---------- UI actions ---------- + + private void RefreshPorts() + { + ComPortComboBox.RemoveAll(); + _ports = ArduinoConnection.ListPorts(); + + foreach (string p in _ports) + ComPortComboBox.AppendText(p); + + if (_ports.Length > 0) + { + ComPortComboBox.Active = 0; + AppendLog(_lang.Format("Log.PortsFound", _ports.Length)); + } + else + { + AppendLog(_lang["Log.NoPorts"]); + } + } + + private void OnConnectClicked(object sender, EventArgs e) + { + if (_arduino.IsOpen) + { + _arduino.Disconnect(); + return; + } + + string port = ComPortComboBox.ActiveText; + if (string.IsNullOrEmpty(port)) + { + AppendLog(_lang["Log.SelectPort"]); + return; + } + _arduino.Connect(port); + } + + private async void OnAutoConnectClicked(object sender, EventArgs e) + { + AutoConnectButton.Sensitive = false; + ConnectButton.Sensitive = false; + AppendLog(_lang["Log.Scanning"]); + + string found = await _arduino.DetectPortAsync(); + + if (found != null) + { + RefreshPorts(); + SelectPort(found); + _arduino.Connect(found); + } + else + { + AppendLog(_lang["Log.DeviceNotFound"]); + } + + AutoConnectButton.Sensitive = true; + ConnectButton.Sensitive = true; + } + + private void OnBackClicked(object sender, EventArgs e) + { + // Hide(); + BackRequested?.Invoke(this, EventArgs.Empty); + } + + private void SelectPort(string portName) + { + for (int i = 0; i < _ports.Length; i++) + { + if (_ports[i] == portName) + { + ComPortComboBox.Active = i; + return; + } + } + } + + // ---------- device events ---------- + + private void OnConnectionChanged(bool connected) + { + _connected = connected; + + ConnectButton.Label = connected + ? _lang["ConnectButton.Disconnect"] + : _lang["ConnectButton.Connect"]; + + StatusLabel.Text = connected + ? _lang.Format("StatusLabel.Connected", _arduino.PortName) + : _lang["StatusLabel.Disconnected"]; + + ComPortComboBox.Sensitive = !connected; + RefreshButton.Sensitive = !connected; + + StyleContext ctx = StatusLabel.StyleContext; + ctx.RemoveClass(connected ? "status-off" : "status-on"); + ctx.AddClass(connected ? "status-on" : "status-off"); + } + + private void OnLineReceived(string line) + { + // Same filtering Form2.cs applied before parsing. + if (line.StartsWith("Send Status:")) return; + if (line.StartsWith("\u2713") || line.StartsWith("\u2717")) return; + + if (line.Contains("ESPNOW_CONNECTED")) { AppendLog(_lang["Log.EspNowConnected"]); return; } + if (line.Contains("ESPNOW_DISCONNECTED")) { AppendLog(_lang["Log.EspNowDisconnected"]); return; } + + AppendLog(line); + } + + // ---------- log ---------- + + public void AppendLog(string message) + { + TextBuffer buf = LogtextBox.Buffer; + + TextIter end = buf.EndIter; + buf.Insert(ref end, $"[{DateTime.Now:HH:mm:ss.fff}] {message}\n"); + + if (buf.LineCount > MaxLogLines) + { + TextIter start = buf.StartIter; + TextIter cut = buf.GetIterAtLine(buf.LineCount - MaxLogLines); + buf.Delete(ref start, ref cut); + } + + TextMark mark = buf.CreateMark(null, buf.EndIter, false); + LogtextBox.ScrollToMark(mark, 0, false, 0, 0); + buf.DeleteMark(mark); + } +} \ No newline at end of file diff --git a/program/SoundLooper.cs b/program/SoundLooper.cs new file mode 100644 index 0000000..311d4b6 --- /dev/null +++ b/program/SoundLooper.cs @@ -0,0 +1,165 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Media; +using System.Threading; + +public class SoundLooper : IDisposable +{ + // ── unix backend ── + private Process _proc; + private Thread _thread; + private volatile bool _running; + private string _path; + + // ── windows backend ── + private SoundPlayer _player; + + public event Action Log; + + public bool IsPlaying { get { return _running; } } + + private static bool IsWindows + { + get + { + var p = Environment.OSVersion.Platform; + return p != PlatformID.Unix && p != PlatformID.MacOSX; + } + } + + public void Start(string wavPath) + { + Stop(); + + if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) + { + Emit("Sound file not found: " + wavPath); + return; + } + + _path = wavPath; + + if (IsWindows) StartWindows(); + else StartUnix(); + } + + public void Stop() + { + if (!_running) return; + _running = false; + + if (_player != null) + { + try { _player.Stop(); } catch { } + try { _player.Dispose(); } catch { } + _player = null; + } + + try + { + var p = _proc; + if (p != null && !p.HasExited) p.Kill(); + } + catch { } + _proc = null; + + if (_thread != null && _thread.IsAlive) _thread.Join(500); + _thread = null; + + Emit("Looping stopped"); + } + + // ── windows: SoundPlayer loops natively, no thread needed ────────── + + private void StartWindows() + { + try + { + _player = new SoundPlayer(_path); + _player.Load(); // throws here if the WAV isn't plain PCM + _player.PlayLooping(); // gapless, runs until Stop() + _running = true; + Emit("Looping started (SoundPlayer): " + _path); + } + catch (Exception ex) + { + Emit("SoundPlayer failed for " + _path + ": " + ex.Message); + _player = null; + _running = false; + } + } + + // ── unix: respawn a CLI player each pass ─────────────────────────── + + private void StartUnix() + { + _running = true; + _thread = new Thread(LoopWorker); + _thread.IsBackground = true; + _thread.Start(); + Emit("Looping started (" + PlayerCommand() + "): " + _path); + } + + private void LoopWorker() + { + while (_running) + { + try + { + var psi = new ProcessStartInfo + { + FileName = PlayerCommand(), + Arguments = "\"" + _path + "\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + _proc = Process.Start(psi); + _proc.WaitForExit(); + } + catch (Exception ex) + { + Emit("Playback error: " + ex.Message); + _running = false; + return; + } + } + } + + private static string _player_cmd; + private static string PlayerCommand() + { + if (_player_cmd != null) return _player_cmd; + _player_cmd = Exists("paplay") ? "paplay" : "aplay"; + return _player_cmd; + } + + private static bool Exists(string cmd) + { + try + { + var p = Process.Start(new ProcessStartInfo + { + FileName = "which", + Arguments = cmd, + UseShellExecute = false, + RedirectStandardOutput = true + }); + p.WaitForExit(); + return p.ExitCode == 0; + } + catch { return false; } + } + + private void Emit(string msg) + { + var h = Log; + if (h != null) h(msg); + else Console.WriteLine("[SoundLooper] " + msg); + } + + public void Dispose() { Stop(); } +} \ No newline at end of file diff --git a/program/SoundWindow.cs b/program/SoundWindow.cs new file mode 100644 index 0000000..e8b4488 --- /dev/null +++ b/program/SoundWindow.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +/// Controller for notebook page 2 (SoundBox). Not a Gtk.Window — same +/// arrangement as SettingsWindow: it drives widgets inside the shared root. +public class SoundWindow +{ + [UI] private Label ConditionNameLabel = null; + [UI] private Grid SoundButtonGrid = null; + [UI] private Button SoundBackButton = null; + + private const int Columns = 2; + + private readonly List _allCases; + private readonly List _path = new List(); + + /// Raised when the drill-down reaches a leaf case. + public event EventHandler CaseSelected; + + /// Raised when Back is pressed at the top level. + public event EventHandler BackRequested; + + public SoundWindow(Builder builder) + { + 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)); + + _allCases = CaseDefinition.LoadCasesFromCsv(AppFile("cases.csv")); + if (_allCases.Count == 0) + Console.WriteLine("WARNING: no cases loaded — check cases.csv"); + + SoundBackButton.Clicked += OnBackClicked; + } + + /// Call every time the page becomes visible. + public void Reset() + { + _path.Clear(); + Render(); + } + + /// Resolve next to the .exe, NOT the working directory. + public static string AppFile(string relative) + { + string dir = Path.GetDirectoryName( + System.Reflection.Assembly.GetExecutingAssembly().Location); + return Path.Combine(dir, relative); + } + + // ── one method covers all three levels ───────────────────────────── + + private void Render() + { + var matches = _allCases.Where(MatchesPath).ToList(); + + // Distinct values one level deeper than where we currently are. + 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); + + // Landed on a leaf? Play it and stay put. + var leaf = _allCases.FirstOrDefault( + c => c.TreePath.Length == _path.Count && MatchesPath(c)); + + if (leaf != null) + { + Play(leaf); + _path.RemoveAt(_path.Count - 1); + return; + } + + Render(); + }); + } + + private bool MatchesPath(CaseDefinition c) + { + string[] p = c.TreePath; + if (p.Length < _path.Count) return false; + for (int i = 0; i < _path.Count; i++) + if (!string.Equals(p[i], _path[i], StringComparison.Ordinal)) return false; + return true; + } + + private void BuildButtons(IEnumerable labels, Action onPick) + { + foreach (var child in SoundButtonGrid.Children) + { + SoundButtonGrid.Remove(child); + child.Destroy(); + } + + int i = 0; + foreach (string text in labels) + { + string captured = text; // don't close over the loop variable + var btn = new Button(captured); + btn.Hexpand = true; + btn.Clicked += (s, e) => onPick(captured); + SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1); + i++; + } + + SoundButtonGrid.ShowAll(); // widgets made in code start hidden + } + + private void Play(CaseDefinition c) + { + if (string.IsNullOrWhiteSpace(c.SoundPath)) + { + Console.WriteLine("No Sound_File for case " + c.Number); + return; + } + + WavePlayer.Stop(); // the map page owns audio from here on + + var h = CaseSelected; + if (h != null) h(this, c); + + // string full = AppFile(c.SoundPath); // "sound/SND200.wav" -> absolute + // if (!File.Exists(full)) + // { + // Console.WriteLine("Sound file missing: " + full); + // return; + // } + + // ConditionNameLabel.Text = string.Join(" : ", _path); + // WavePlayer.Play(full); + } + + private void OnBackClicked(object sender, EventArgs e) + { + if (_path.Count > 0) + { + _path.RemoveAt(_path.Count - 1); + Render(); + } + else + { + WavePlayer.Stop(); + if (BackRequested != null) BackRequested(this, EventArgs.Empty); + } + } +} \ No newline at end of file diff --git a/program/WavePlayer.cs b/program/WavePlayer.cs new file mode 100644 index 0000000..00cd07a --- /dev/null +++ b/program/WavePlayer.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using System.IO; + +public static class WavePlayer +{ + private static Process _current; + + private static bool IsUnix + { + get + { + int p = (int)Environment.OSVersion.Platform; + return p == 4 || p == 6 || p == 128; + } + } + + public static void Play(string absolutePath) + { + Stop(); + + if (!IsUnix) + { + try + { + var sp = new System.Media.SoundPlayer(absolutePath); + sp.Play(); + } + catch (Exception ex) { Console.WriteLine("Playback failed: " + ex.Message); } + return; + } + + foreach (string player in new[] { "paplay", "aplay" }) + { + try + { + var psi = new ProcessStartInfo(player, "\"" + absolutePath + "\"") + { + UseShellExecute = false, + RedirectStandardError = true + }; + _current = Process.Start(psi); + return; + } + catch { /* not installed, try the next one */ } + } + + Console.WriteLine("No audio player found — install pulseaudio-utils or alsa-utils"); + } + + public static void Stop() + { + try + { + if (_current != null && !_current.HasExited) _current.Kill(); + } + catch { } + _current = null; + } +} \ No newline at end of file diff --git a/program/homepage.glade b/program/homepage.glade new file mode 100644 index 0000000..7c21a1d --- /dev/null +++ b/program/homepage.glade @@ -0,0 +1,380 @@ + + + + + + False + 1280 + 800 + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + + + False + 1280 + 800 + + + + True + False + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + False + EARS + 1280 + 800 + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + True + True + + + 1 + 3 + + + + + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program/homepage.glade~ b/program/homepage.glade~ new file mode 100644 index 0000000..fade189 --- /dev/null +++ b/program/homepage.glade~ @@ -0,0 +1,347 @@ + + + + + + False + 1280 + 800 + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + + + False + + + + + + False + EARS + 1280 + 800 + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + True + True + + + 1 + 3 + + + + + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program/homepageall.glade b/program/homepageall.glade new file mode 100644 index 0000000..03f30c2 --- /dev/null +++ b/program/homepageall.glade @@ -0,0 +1,487 @@ + + + + + + False + + + True + True + False + False + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 3 + + + + + A / あ + True + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + 1 + + + + + + + + + + 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/homepageallv3.glade b/program/homepageallv3.glade new file mode 100644 index 0000000..284cfee --- /dev/null +++ b/program/homepageallv3.glade @@ -0,0 +1,445 @@ + + + + + + False + + + True + True + False + False + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 3 + + + + + A / あ + True + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + 1 + + + + + True + False + vertical + + + True + False + label + + + + + + False + True + 0 + + + + + True + True + False + False + never + in + + + True + False + + + + True + False + 6 + 6 + True + + + + + + + + + + + + + + + + + + + + + + + + + True + True + 1 + + + + + Back + True + True + True + end + True + right + + + False + True + 4 + + + + + 2 + + + + + + + + + diff --git a/program/homepageallv4.glade b/program/homepageallv4.glade new file mode 100644 index 0000000..6f3fd9c --- /dev/null +++ b/program/homepageallv4.glade @@ -0,0 +1,472 @@ + + + + + + False + + + + True + False + + + True + False + True + True + bodyImage + True + + + 1 + 0 + + + + + + + + + + False + + + True + True + False + False + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 3 + + + + + A / あ + True + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + 1 + + + + + True + False + vertical + + + True + False + label + + + + + + False + True + 0 + + + + + True + True + False + False + never + in + + + True + False + + + + True + False + 6 + 6 + True + + + + + + + + + + + + + + + + + + + + + + + + + True + True + 1 + + + + + Back + True + True + True + end + True + right + + + False + True + 4 + + + + + 2 + + + + + + + + + diff --git a/program/homepageallv5.glade b/program/homepageallv5.glade new file mode 100644 index 0000000..dd9fd65 --- /dev/null +++ b/program/homepageallv5.glade @@ -0,0 +1,499 @@ + + + + + + False + + + True + True + False + False + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 3 + + + + + A / あ + True + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + 1 + + + + + True + False + vertical + + + True + False + label + + + + + + False + True + 0 + + + + + True + True + False + False + never + in + + + True + False + + + + True + False + 6 + 6 + True + + + + + + + + + + + + + + + + + + + + + + + + + True + True + 1 + + + + + Back + True + True + True + end + True + right + + + False + True + 4 + + + + + 2 + + + + + True + False + vertical + 6 + + + True + False + label + + + + + + False + True + 0 + + + + + True + False + True + True + + + True + True + 1 + + + + + Back + True + True + True + end + True + + + False + True + 2 + + + + + 3 + + + + + + + + + diff --git a/program/homepageallv6.glade b/program/homepageallv6.glade new file mode 100644 index 0000000..4630bf4 --- /dev/null +++ b/program/homepageallv6.glade @@ -0,0 +1,828 @@ + + + + + + False + + + True + True + False + False + + + + True + False + 3 + 5 + + + Normal Session + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 1 + + + + + Position Recording Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 2 + + + + + Exam Mode + True + True + True + 50 + 50 + 50 + 50 + True + True + + + 1 + 3 + + + + + A / あ + True + True + True + True + + + 3 + 0 + + + + + True + False + Language + + + 2 + 0 + + + + + Close + True + True + True + True + + + 4 + 4 + + + + + Settings + True + True + True + + + 4 + 3 + + + + + True + False + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + True + False + 3 + 3 + True + + + True + False + center + True + True + COM Port + + + 0 + 0 + + + + + True + False + center + True + True + + + 1 + 0 + + + + + Connect + True + True + True + start + center + True + True + + + 2 + 0 + + + + + Auto Connect + True + True + True + center + start + True + True + + + 1 + 1 + + + + + Back + True + True + True + end + end + True + True + + + 2 + 2 + + + + + True + False + Log + + + 0 + 2 + + + + + True + True + True + True + in + + + True + True + False + True + + + + + 1 + 2 + + + + + True + False + vertical + + + Refresh Ports + True + True + True + center + start + + + False + True + 0 + + + + + True + False + Disconnected + + + False + True + 1 + + + + + 2 + 1 + + + + + + + + 1 + + + + + True + False + vertical + + + True + False + label + + + + + + False + True + 0 + + + + + True + True + False + False + never + in + + + True + False + + + + True + False + 6 + 6 + True + + + + + + + + + + + + + + + + + + + + + + + + + True + True + 1 + + + + + Back + True + True + True + end + True + right + + + False + True + 4 + + + + + 2 + + + + + True + False + horizontal + 12 + + + True + True + 340 + never + none + + + True + False + + + True + False + vertical + 10 + 12 + 12 + 12 + 12 + + + True + False + 0 + in + + + True + False + - + True + 0 + 8 + 8 + 6 + 6 + + + + + True + False + 選択中の症例 + + + + + False + True + 0 + + + + + True + False + 0 + in + + + True + False + vertical + 8 + 8 + 6 + 6 + + + スケルトン + True + True + False + True + True + + + False + True + 0 + + + + + ジャケット + True + True + False + True + BodySkeletonRadio + + + False + True + 1 + + + + + + + True + False + 体の画像選択 + + + + + False + True + 1 + + + + + True + False + 8 + + + True + False + サイズ: + + + False + True + 0 + + + + + True + False + 0 + + L + XL + + + + False + True + 1 + + + + + False + True + 2 + + + + + True + False + 0 + in + + + True + False + True + 8 + 8 + 6 + 6 + + + + True + True + False + True + False + + + True + True + 0 + + + + + + True + True + False + False + ViewFrontRadio + + + True + True + 1 + + + + + + True + True + False + False + ViewFrontRadio + + + True + True + 2 + + + + + + True + True + False + False + ViewFrontRadio + + + True + True + 3 + + + + + + + True + False + 視点 + + + + + False + True + 3 + + + + + 番号を隠す + True + True + False + True + True + + + False + True + 4 + + + + + 黒画素でも再生 + True + True + False + True + + + False + True + 5 + + + + + 再生テスト + True + True + True + + + False + True + 6 + + + + + True + False + vertical + 8 + True + + + True + True + 7 + + + + + 戻る + True + True + True + end + + + False + True + 8 + + + + + + + + + False + True + 0 + + + + + True + False + vertical + 6 + True + 12 + 12 + 12 + + + True + False + label + + + + + + False + True + 0 + + + + + True + False + True + True + + + True + True + 1 + + + + + True + True + 1 + + + + + 3 + + + + + + + + + diff --git a/program/info_eng.csv b/program/info_eng.csv new file mode 100644 index 0000000..0335a1a --- /dev/null +++ b/program/info_eng.csv @@ -0,0 +1,26 @@ +key,text +Option1Button,Normal Session +Option2Button,Position Recording Mode +Option3Button,Exam Mode +SettingButton,Settings +CloseButton,Close +LanguageLabel,Language +LanguageToggle,あ +ComPortLabel,COM Port +LogLabel,Log +AutoConnectButton,Auto Connect +RefreshButton,Refresh Ports +SettingBackButton,Back +ConnectButton.Connect,Connect +ConnectButton.Disconnect,Disconnect +StatusLabel.Connected,Connected ({0}) +StatusLabel.Disconnected,Disconnected +ConnectionIcon.Connected,Connected to {0} +ConnectionIcon.Disconnected,Device not connected +Log.PortsFound,{0} port(s) found +Log.NoPorts,No serial ports found +Log.SelectPort,Select a COM port first +Log.Scanning,Scanning ports for device... +Log.DeviceNotFound,Device not found — use manual connection +Log.EspNowConnected,[ESP-NOW] Peer connected +Log.EspNowDisconnected,[ESP-NOW] Peer disconnected \ No newline at end of file diff --git a/program/info_jpn.csv b/program/info_jpn.csv new file mode 100644 index 0000000..eb576d7 --- /dev/null +++ b/program/info_jpn.csv @@ -0,0 +1,26 @@ +key,text +Option1Button,通常セッション +Option2Button,位置記録モード +Option3Button,試験モード +SettingButton,設定 +CloseButton,終了 +LanguageLabel,言語 +LanguageToggle,A +ComPortLabel,COMポート +LogLabel,ログ +AutoConnectButton,自動接続 +RefreshButton,ポート更新 +SettingBackButton,戻る +ConnectButton.Connect,接続 +ConnectButton.Disconnect,切断 +StatusLabel.Connected,接続済み ({0}) +StatusLabel.Disconnected,未接続 +ConnectionIcon.Connected,{0} に接続しました +ConnectionIcon.Disconnected,デバイスが接続されていません +Log.PortsFound,ポートが {0} 個見つかりました +Log.NoPorts,シリアルポートが見つかりません +Log.SelectPort,COMポートを選択してください +Log.Scanning,デバイスを検索中... +Log.DeviceNotFound,デバイスが見つかりません — 手動で接続してください +Log.EspNowConnected,[ESP-NOW] 相手が接続しました +Log.EspNowDisconnected,[ESP-NOW] 相手が切断しました \ No newline at end of file diff --git a/program/lang_eng.css b/program/lang_eng.css new file mode 100644 index 0000000..fcf83ec --- /dev/null +++ b/program/lang_eng.css @@ -0,0 +1,69 @@ +#Option1Button { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#Option2Button { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#Option3Button { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#CloseButton { + font-family: 'Sans'; + font-size: 18px; + font-weight: bold; + font-style: normal; +} + +#StatusLabel.status-on { color: #2e7d32; font-weight: bold; } +#StatusLabel.status-off { color: #c62828; font-weight: bold; } + +/* Setting Window +#ConnectButton { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#AutoConnectButton { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#Option3Button { + font-family: 'Sans'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#CloseButton { + font-family: 'Sans'; + font-size: 18px; + font-weight: bold; + font-style: normal; +} */ + +/* [UI] private Label ComPortLabel = null; + [UI] private ComboBoxText ComPortComboBox = null; + [UI] private Button ConnectButton = null; + [UI] private Button AutoConnectButton = null; + [UI] private Button RefreshButton = null; + [UI] private Button BackButton = null; + [UI] private Label StatusLabel = null; + [UI] private Label LogLabel = null; + [UI] private TextView LogtextBox = null; */ \ No newline at end of file diff --git a/program/lang_jpn.css b/program/lang_jpn.css new file mode 100644 index 0000000..13b87e2 --- /dev/null +++ b/program/lang_jpn.css @@ -0,0 +1,30 @@ +#Option1Button { + font-family: 'Noto Sans JP'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#Option2Button { + font-family: 'Noto Sans JP'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#Option3Button { + font-family: 'Noto Sans JP'; + font-size: 20px; + font-weight: normal; + font-style: normal; +} + +#CloseButton { + font-family: 'Noto Sans JP'; + font-size: 18px; + font-weight: bold; + font-style: normal; +} + +#StatusLabel.status-on { color: #2e7d32; font-weight: bold; } +#StatusLabel.status-off { color: #c62828; font-weight: bold; } \ No newline at end of file diff --git a/program/program.cs b/program/program.cs new file mode 100644 index 0000000..f9db539 --- /dev/null +++ b/program/program.cs @@ -0,0 +1,16 @@ +using System; +using Gtk; + +class Program +{ + [STAThread] + static void Main(string[] args) + { + Application.Init(); + + var app = new MainWindow(); + app.ShowAll(); + + Application.Run(); + } +} \ No newline at end of file diff --git a/program/style.css b/program/style.css new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/program/style.css diff --git a/programdeltafixer/CalibrationWindow.cs b/programdeltafixer/CalibrationWindow.cs new file mode 100644 index 0000000..e2bf735 --- /dev/null +++ b/programdeltafixer/CalibrationWindow.cs @@ -0,0 +1,600 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; +using IOPath = System.IO.Path; + +/// Standalone tuning tool: circle cluster scaling, sound-map zoom, +/// and sound-map position over the jacket. Writes values you paste +/// back into MapWindow.cs. +public class CalibrationWindow : Window +{ + [UI] private Box ControlBox = null; + [UI] private DrawingArea CalibArea = null; + [UI] private TextView OutputView = null; + [UI] private Button DumpButton = null; + [UI] private Button SaveButton = null; + [UI] private Button ResetButton = null; + + // ── per (size, view) tunables ── + private class Tune + { + public double OffsetX = 25.0; // circle cluster shift (subtracted) + public double OffsetY = 50.0; + public double SizeScale = 1.0; // circle diameter multiplier + public double Cluster = 1.0; // 1.0 = no clustering + public double MapZoom = 1.0; // fraction of body the map fills + public double MapX = 0.0; // map nudge, body pixels + public double MapY = 0.0; + public double MapAlpha = 0.7; + public double FitMargin = 40.0; // Form2: panel - 40 (front: -100) + + public Tune Clone() { return (Tune)MemberwiseClone(); } + } + + private readonly Dictionary _tunes = new Dictionary(); + private readonly Dictionary _spins = new Dictionary(); + + private ComboBoxText _viewCombo, _sizeCombo, _bodyCombo, _caseCombo; + private CheckButton _showCircles, _showMap, _showBody, _showGuides; + + private JacketData _jacket; + private List _cases; + + private Gdk.Pixbuf _body, _map; + private string _view = "front"; + private string _size = "L"; + private bool _skeleton = true; + private bool _building; // suppress handlers while repopulating spins + + private const int CircleDiameter = 50; + private const string CalibFile = "calib.txt"; + + private string Key { get { return _size + ":" + _view; } } + private bool _listed; + private Tune T + { + get + { + if (!_tunes.ContainsKey(Key)) _tunes[Key] = DefaultFor(_view); + return _tunes[Key]; + } + } + + private static Tune DefaultFor(string view) + { + var t = new Tune(); + switch (view) + { + case "back": t.OffsetX = 20; t.OffsetY = 20; t.SizeScale = 0.5; break; + case "right": t.OffsetX = 10; t.OffsetY = -150; t.Cluster = 0.5; break; + case "left": t.OffsetX = -50; t.OffsetY = -150; t.Cluster = 0.4; break; + default: t.OffsetX = 25; t.OffsetY = 50; t.FitMargin = 100; break; + } + return t; + } + + // ── construction ─────────────────────────────────────────────────── + + public CalibrationWindow() : this(CreateBuilder()) { } + + private static Builder CreateBuilder() + { + var b = new Builder(); + b.AddFromFile("calibrate.glade"); + return b; + } + + private CalibrationWindow(Builder builder) : base(builder.GetObject("CalibRoot").Handle) + { + builder.Autoconnect(this); + + _jacket = JacketData.Load(); + _cases = CaseDefinition.LoadCasesFromCsv("cases.csv"); + + BuildControls(); + LoadCalib(); + + CalibArea.Drawn += OnDraw; + DumpButton.Clicked += (s, e) => Emit(DumpConstants()); + SaveButton.Clicked += (s, e) => SaveCalib(); + ResetButton.Clicked += (s, e) => { _tunes[Key] = DefaultFor(_view); SyncSpins(); Redraw(); }; + + DeleteEvent += (o, a) => Application.Quit(); + + ReloadImages(); + SyncSpins(); + } + + private void BuildControls() + { + ControlBox.PackStart(Header("Source"), false, false, 0); + + _viewCombo = Combo(new[] { "front", "back", "right", "left" }, 0, v => + { + _view = v; ReloadImages(); SyncSpins(); Redraw(); + }); + ControlBox.PackStart(Row("View", _viewCombo), false, false, 0); + + _sizeCombo = Combo(new[] { "L", "XL" }, 0, v => + { + _size = v; SyncSpins(); Redraw(); + }); + ControlBox.PackStart(Row("Jacket size", _sizeCombo), false, false, 0); + + _bodyCombo = Combo(new[] { "Skeleton", "Jacket" }, 0, v => + { + _skeleton = (v == "Skeleton"); ReloadImages(); Redraw(); + }); + ControlBox.PackStart(Row("Body", _bodyCombo), false, false, 0); + + _caseCombo = new ComboBoxText(); + foreach (var c in _cases) + _caseCombo.AppendText(c.Number + " " + string.Join(":", c.TreePath)); + if (_cases.Count > 0) _caseCombo.Active = 0; + _caseCombo.Changed += (s, e) => { ReloadImages(); Redraw(); }; + ControlBox.PackStart(Row("Case", _caseCombo), false, false, 0); + + ControlBox.PackStart(Header("Sound map"), false, false, 0); + AddSpin("MapZoom", "Zoom (x body)", 0.10, 3.00, 0.01, 2); + AddSpin("MapX", "Offset X (px)", -600, 600, 1, 0); + AddSpin("MapY", "Offset Y (px)", -600, 600, 1, 0); + AddSpin("MapAlpha", "Opacity", 0.00, 1.00, 0.05, 2); + + ControlBox.PackStart(Header("Circle cluster"), false, false, 0); + AddSpin("OffsetX", "Offset X (px)", -600, 600, 1, 0); + AddSpin("OffsetY", "Offset Y (px)", -600, 600, 1, 0); + AddSpin("SizeScale", "Circle size", 0.05, 4.00, 0.05, 2); + AddSpin("Cluster", "Cluster factor", 0.05, 2.00, 0.05, 2); + AddSpin("FitMargin", "Fit margin (px)", 0, 400, 5, 0); + + ControlBox.PackStart(Header("Display"), false, false, 0); + _showBody = Check("Show body", true); + _showMap = Check("Show map", true); + _showCircles = Check("Show circles", true); + _showGuides = Check("Show centre guides", true); + } + + // ── widget helpers ───────────────────────────────────────────────── + + private Label Header(string text) + { + var l = new Label(); + l.Markup = "" + text + ""; + l.Xalign = 0; + l.MarginTop = 8; + return l; + } + + private Box Row(string label, Widget w) + { + var box = new Box(Orientation.Horizontal, 6); + var l = new Label(label); + l.Xalign = 0; + l.WidthRequest = 130; + box.PackStart(l, false, false, 0); + box.PackStart(w, true, true, 0); + return box; + } + + private ComboBoxText Combo(string[] items, int active, Action onChange) + { + var c = new ComboBoxText(); + foreach (string s in items) c.AppendText(s); + c.Active = active; + c.Changed += (s, e) => { if (!_building && c.ActiveText != null) onChange(c.ActiveText); }; + return c; + } + + private CheckButton Check(string label, bool active) + { + var c = new CheckButton(label); + c.Active = active; + c.Toggled += (s, e) => Redraw(); + ControlBox.PackStart(c, false, false, 0); + return c; + } + + private void AddSpin(string field, string label, double min, double max, double step, uint digits) + { + var sb = new SpinButton(min, max, step); + sb.Digits = digits; + sb.Numeric = true; + sb.ValueChanged += (s, e) => + { + if (_building) return; + Set(field, sb.Value); + Redraw(); + }; + _spins[field] = sb; + ControlBox.PackStart(Row(label, sb), false, false, 0); + } + + private void Set(string field, double v) + { + var t = T; + switch (field) + { + case "OffsetX": t.OffsetX = v; break; + case "OffsetY": t.OffsetY = v; break; + case "SizeScale": t.SizeScale = v; break; + case "Cluster": t.Cluster = v; break; + case "MapZoom": t.MapZoom = v; break; + case "MapX": t.MapX = v; break; + case "MapY": t.MapY = v; break; + case "MapAlpha": t.MapAlpha = v; break; + case "FitMargin": t.FitMargin = v; break; + } + } + + private double Get(string field) + { + var t = T; + switch (field) + { + case "OffsetX": return t.OffsetX; + case "OffsetY": return t.OffsetY; + case "SizeScale": return t.SizeScale; + case "Cluster": return t.Cluster; + case "MapZoom": return t.MapZoom; + case "MapX": return t.MapX; + case "MapY": return t.MapY; + case "MapAlpha": return t.MapAlpha; + case "FitMargin": return t.FitMargin; + } + return 0; + } + + private void SyncSpins() + { + _building = true; + foreach (var kv in _spins) kv.Value.Value = Get(kv.Key); + _building = false; + } + + private void Redraw() { CalibArea.QueueDraw(); } + + // ── images ───────────────────────────────────────────────────────── + + private void ReloadImages() + { + if (_body != null) { _body.Dispose(); _body = null; } + if (_map != null) { _map.Dispose(); _map = null; } + + _body = LoadBody(); + _map = LoadMap(); + } + + private Gdk.Pixbuf LoadBody() + { + string stem; + if (_skeleton) + { + switch (_view) + { + case "back": stem = "jacketBackBody"; break; + case "right": stem = "jacketRightBody"; break; + case "left": stem = "jacketLeftBody"; break; + default: stem = "jacketFrontBody"; break; + } + } + else + { + switch (_view) + { + case "back": stem = "jacketBack"; break; + case "right": stem = "jacketRight"; break; + case "left": stem = "jacketLeft"; break; + default: stem = "jacketFront"; break; + } + } + + foreach (string ext in new[] { ".png", ".jpg", ".bmp" }) + { + string p = IOPath.Combine("map", stem + ext); + if (File.Exists(p)) return Safe(p); + } + + Emit("body not found: map/" + stem + ".*"); + ListMapFolder(); + return null; + } + + private void ListMapFolder() + { + if (_listed) return; + _listed = true; + + Emit("cwd: " + Directory.GetCurrentDirectory()); + + if (!Directory.Exists("map")) { Emit("map/ does not exist"); return; } + + var sb = new StringBuilder(); + sb.AppendLine("map/ contains:"); + foreach (string f in Directory.GetFiles("map")) + sb.AppendLine(" " + IOPath.GetFileName(f)); + Emit(sb.ToString()); + } + + private Gdk.Pixbuf LoadMap() + { + if (_caseCombo == null || _caseCombo.Active < 0 || _caseCombo.Active >= _cases.Count) + return null; + + var c = _cases[_caseCombo.Active]; + string rel; + switch (_view) + { + case "back": rel = c.MapBack; break; + case "right": rel = c.MapRight; break; + case "left": rel = c.MapLeft; break; + default: rel = c.MapFront; break; + } + if (string.IsNullOrEmpty(rel)) rel = c.MapFront; + if (string.IsNullOrEmpty(rel)) return null; + + string full = rel; + if (!File.Exists(full)) { Emit("map not found: " + rel); return null; } + + var raw = Safe(full); + if (raw == null) return null; + + var keyed = raw.AddAlpha(true, 0, 0, 0); + raw.Dispose(); + return keyed; + } + + private Gdk.Pixbuf Safe(string path) + { + try { return new Gdk.Pixbuf(path); } + catch (Exception ex) { Emit("load failed " + path + ": " + ex.Message); return null; } + } + + // ── the draw ─────────────────────────────────────────────────────── + + private void OnDraw(object o, DrawnArgs args) + { + var cr = args.Cr; + int w = CalibArea.AllocatedWidth, h = CalibArea.AllocatedHeight; + + cr.SetSourceRGB(1, 1, 1); + cr.Rectangle(0, 0, w, h); + cr.Fill(); + + Gdk.Pixbuf refPb = _body ?? _map; + if (refPb == null) { args.RetVal = true; return; } + + double s = Math.Min((double)w / refPb.Width, (double)h / refPb.Height); + double ox = (w - refPb.Width * s) / 2.0; + double oy = (h - refPb.Height * s) / 2.0; + + cr.Save(); + cr.Translate(ox, oy); + cr.Scale(s, s); // body-pixel space + + if (_body != null && _showBody.Active) + { + Gdk.CairoHelper.SetSourcePixbuf(cr, _body, 0, 0); + cr.Paint(); + } + + if (_map != null && _showMap.Active) + { + double ms = Math.Min((double)refPb.Width / _map.Width, + (double)refPb.Height / _map.Height) * T.MapZoom; + double mw = _map.Width * ms, mh = _map.Height * ms; + double mx = (refPb.Width - mw) / 2.0 + T.MapX; + double my = (refPb.Height - mh) / 2.0 + T.MapY; + + cr.Save(); + cr.Translate(mx, my); + cr.Scale(ms, ms); + Gdk.CairoHelper.SetSourcePixbuf(cr, _map, 0, 0); + cr.PaintWithAlpha(T.MapAlpha); + cr.Restore(); + + if (_showGuides.Active) + { + cr.NewPath(); + cr.SetSourceRGBA(1, 0, 0, 0.6); + cr.LineWidth = 1.5; + cr.Rectangle(mx, my, mw, mh); + cr.Stroke(); + } + } + + if (_showCircles.Active) DrawCircles(cr, refPb); + + if (_showGuides.Active) + { + cr.NewPath(); + cr.SetSourceRGBA(0, 0, 1, 0.35); + cr.LineWidth = 1.0; + cr.MoveTo(refPb.Width / 2.0, 0); + cr.LineTo(refPb.Width / 2.0, refPb.Height); + cr.MoveTo(0, refPb.Height / 2.0); + cr.LineTo(refPb.Width, refPb.Height / 2.0); + cr.Stroke(); + } + + cr.Restore(); + args.RetVal = true; + } + + private void DrawCircles(Cairo.Context cr, Gdk.Pixbuf refPb) + { + var circles = _jacket.Get(_size, _view); + if (circles.Count == 0) return; + + int panelW = refPb.Width, panelH = refPb.Height; + + float minX = float.MaxValue, maxX = float.MinValue; + float minY = float.MaxValue, maxY = float.MinValue; + foreach (var c in circles) + { + if (c.X < minX) minX = c.X; + if (c.X > maxX) maxX = c.X; + if (c.Y < minY) minY = c.Y; + if (c.Y > maxY) maxY = c.Y; + } + + double availW = panelW - T.FitMargin, availH = panelH - T.FitMargin; + const double circleRadius = 2.1 / 2; + double dataW = (maxX - minX) + circleRadius * 2; + double dataH = (maxY - minY) + circleRadius * 2; + if (dataW <= 0 || dataH <= 0) return; + + double scale = Math.Min(availW / dataW, availH / dataH) * 0.9; + + double offX = (panelW - (maxX - minX) * scale) / 2 - minX * scale; + double offY = (panelH - (maxY - minY) * scale) / 2 - minY * scale; + + // cluster centroid, in already-offset space + double avgX = 0, avgY = 0; + foreach (var c in circles) + { + avgX += c.X * scale + offX - T.OffsetX; + avgY += c.Y * scale + offY - T.OffsetY; + } + avgX /= circles.Count; + avgY /= circles.Count; + + cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold); + + foreach (var c in circles) + { + double x = c.X * scale + offX - T.OffsetX; + double y = c.Y * scale + offY - T.OffsetY; + + if (Math.Abs(T.Cluster - 1.0) > 0.001) + { + x = avgX + (x - avgX) * T.Cluster; + y = avgY + (y - avgY) * T.Cluster; + } + + double d = CircleDiameter * scale * T.SizeScale; + double r = d / 2; + + cr.NewPath(); + cr.Arc(x, y, r, 0, 2 * Math.PI); + cr.SetSourceRGBA(0.5, 0.5, 0.5, 1.0); + cr.LineWidth = Math.Max(1.0, scale); + cr.Stroke(); + + string text = (c.Number % 1 == 0) + ? ((int)c.Number).ToString() + : c.Number.ToString("F1", CultureInfo.InvariantCulture); + + cr.SetFontSize(Math.Max(6.0, d / (c.Inner ? 3 : 4))); + var ext = cr.TextExtents(text); + cr.MoveTo(x - ext.Width / 2 - ext.XBearing, y + ext.Height / 2); + cr.SetSourceRGBA(0.2, 0.2, 0.2, 1.0); + cr.ShowText(text); + cr.NewPath(); + } + } + + // ── output ───────────────────────────────────────────────────────── + + private string DumpConstants() + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + + sb.AppendLine("// generated by CalibrationWindow " + DateTime.Now.ToString("yyyy-MM-dd HH:mm")); + + foreach (string size in new[] { "L", "XL" }) + { + sb.AppendLine("// ---- " + size + " ----"); + foreach (string view in new[] { "front", "back", "right", "left" }) + { + string k = size + ":" + view; + if (!_tunes.ContainsKey(k)) continue; + + var t = _tunes[k]; + string p = (size == "XL" ? "XL_" : "") + view.ToUpper(); + + sb.AppendLine("private const float " + p + "_OFFSET_X = " + t.OffsetX.ToString("F1", inv) + "f;"); + sb.AppendLine("private const float " + p + "_OFFSET_Y = " + t.OffsetY.ToString("F1", inv) + "f;"); + sb.AppendLine("private const float " + p + "_SIZE = " + t.SizeScale.ToString("F2", inv) + "f;"); + sb.AppendLine("private const float " + p + "_CLUSTER = " + t.Cluster.ToString("F2", inv) + "f;"); + sb.AppendLine("private const float " + p + "_FIT_MARGIN = " + t.FitMargin.ToString("F0", inv) + "f;"); + sb.AppendLine("private const double " + p + "_MAP_ZOOM = " + t.MapZoom.ToString("F3", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_X = " + t.MapX.ToString("F1", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_Y = " + t.MapY.ToString("F1", inv) + ";"); + sb.AppendLine("private const double " + p + "_MAP_ALPHA = " + t.MapAlpha.ToString("F2", inv) + ";"); + sb.AppendLine(); + } + } + + return sb.ToString(); + } + + private void SaveCalib() + { + var inv = CultureInfo.InvariantCulture; + var sb = new StringBuilder(); + + sb.AppendLine("# size:view offsetX offsetY sizeScale cluster mapZoom mapX mapY mapAlpha fitMargin"); + foreach (var kv in _tunes) + { + var t = kv.Value; + sb.AppendLine(string.Join("\t", new[] + { + kv.Key, + t.OffsetX.ToString(inv), t.OffsetY.ToString(inv), + t.SizeScale.ToString(inv), t.Cluster.ToString(inv), + t.MapZoom.ToString(inv), t.MapX.ToString(inv), + t.MapY.ToString(inv), t.MapAlpha.ToString(inv), + t.FitMargin.ToString(inv) + })); + } + + try + { + File.WriteAllText(CalibFile, sb.ToString()); + Emit("saved " + CalibFile); + } + catch (Exception ex) { Emit("save failed: " + ex.Message); } + } + + private void LoadCalib() + { + string p = CalibFile; + if (!File.Exists(p)) return; + + var inv = CultureInfo.InvariantCulture; + foreach (string line in File.ReadAllLines(p)) + { + if (line.StartsWith("#") || line.Trim().Length == 0) continue; + string[] f = line.Split('\t'); + if (f.Length < 10) continue; + + var t = new Tune(); + try + { + t.OffsetX = double.Parse(f[1], inv); + t.OffsetY = double.Parse(f[2], inv); + t.SizeScale = double.Parse(f[3], inv); + t.Cluster = double.Parse(f[4], inv); + t.MapZoom = double.Parse(f[5], inv); + t.MapX = double.Parse(f[6], inv); + t.MapY = double.Parse(f[7], inv); + t.MapAlpha = double.Parse(f[8], inv); + t.FitMargin = double.Parse(f[9], inv); + _tunes[f[0]] = t; + } + catch { } + } + + Emit("loaded " + CalibFile); + } + + private void Emit(string msg) + { + OutputView.Buffer.Text = msg + "\n\n" + OutputView.Buffer.Text; + } +} \ No newline at end of file diff --git a/programdeltafixer/CaseDefinition.cs b/programdeltafixer/CaseDefinition.cs new file mode 100644 index 0000000..39b9c85 --- /dev/null +++ b/programdeltafixer/CaseDefinition.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +/// One row of cases.csv. +public class CaseDefinition +{ + public int Number { get; set; } // Number + public string Type { get; set; } // Type 心音 / 呼吸音 + public string CategoryJp { get; set; } // Category + public string SubcategoryJp { get; set; } // Subcategory + public string LocationJp { get; set; } // Location + public string TreeLevel1 { get; set; } // Tree_Level1 + public string TreeLevel2 { get; set; } // Tree_Level2 + public string TreeLevel3 { get; set; } // Tree_Level3 + public string MapFront { get; set; } // Image_File + public string SoundPath { get; set; } // Sound_File + public string MapRight { get; set; } // Image_Right + public string MapLeft { get; set; } // Image_Left + public string MapBack { get; set; } // Image_Back + + public bool IsHeart + { + get { return !string.IsNullOrEmpty(Type) && Type.Contains("心"); } + } + + /// Non-empty levels only, e.g. 呼吸音 → 正常呼吸音 → 気管音. + /// Depth varies per row, which is what drives the drill-down. + public string[] TreePath + { + get + { + var parts = new List(); + foreach (string s in new[] { Type, TreeLevel1, TreeLevel2, TreeLevel3 }) + if (!string.IsNullOrWhiteSpace(s)) parts.Add(s.Trim()); + return parts.ToArray(); + } + } + + public override string ToString() + { + return Number + ": " + string.Join(" : ", TreePath); + } + + // ── loader ───────────────────────────────────────────────────────── + + public static List LoadCasesFromCsv(string path) + { + var list = new List(); + + if (!File.Exists(path)) + { + Console.WriteLine("CSV not found: " + path); + return list; + } + + // Encoding.UTF8 with BOM detection. Reading Japanese as the default + // ANSI codepage gives mojibake that only shows up on the buttons. + string[] lines; + try + { + lines = File.ReadAllLines(path, Encoding.UTF8); + } + catch (Exception ex) + { + Console.WriteLine("Failed to read " + path + ": " + ex.Message); + return list; + } + + if (lines.Length < 2) + { + Console.WriteLine("CSV has no data rows: " + path); + return list; + } + + // Your file is TAB separated; fall back to comma if it gets re-exported. + char sep = lines[0].Contains("\t") ? '\t' : ','; + + // Header name -> column index, so adding or reordering columns is safe. + // string[] header = lines[0].TrimStart('\uFEFF').Split(sep); + string[] header = lines[0].TrimStart(new[] { '\uFEFF' }).Split(sep); + var idx = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 0; i < header.Length; i++) + { + string key = header[i].Trim(); + if (key.Length > 0 && !idx.ContainsKey(key)) idx[key] = i; + } + + if (!idx.ContainsKey("Number")) + { + Console.WriteLine("CSV header has no 'Number' column — wrong file or wrong delimiter?"); + return list; + } + + Func get = (fields, name) => + { + int i; + if (!idx.TryGetValue(name, out i) || i >= fields.Length) return ""; + return fields[i].Trim(); + }; + + int skipped = 0; + + for (int r = 1; r < lines.Length; r++) + { + if (string.IsNullOrWhiteSpace(lines[r])) continue; + + string[] f = lines[r].Split(sep); + + int number; + if (!int.TryParse(get(f, "Number"), NumberStyles.Integer, + CultureInfo.InvariantCulture, out number)) + { + skipped++; + Console.WriteLine("Line " + (r + 1) + ": bad Number, skipped"); + continue; + } + + list.Add(new CaseDefinition + { + Number = number, + Type = get(f, "Type"), + CategoryJp = get(f, "Category"), + SubcategoryJp = get(f, "Subcategory"), + LocationJp = get(f, "Location"), + TreeLevel1 = get(f, "Tree_Level1"), + TreeLevel2 = get(f, "Tree_Level2"), + TreeLevel3 = get(f, "Tree_Level3"), + MapFront = get(f, "Image_File"), + SoundPath = get(f, "Sound_File"), + MapRight = get(f, "Image_Right"), + MapLeft = get(f, "Image_Left"), + MapBack = get(f, "Image_Back") + }); + } + + Console.WriteLine("Loaded " + list.Count + " cases from " + path + + (skipped > 0 ? " (" + skipped + " skipped)" : "")); + return list; + } +} \ No newline at end of file diff --git a/programdeltafixer/JacketData.cs b/programdeltafixer/JacketData.cs new file mode 100644 index 0000000..d09ae52 --- /dev/null +++ b/programdeltafixer/JacketData.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; + +/// One chip position. Mirrors Form2.Circle. +public class Circle +{ + public float Number { get; set; } + public int X { get; set; } // screen-space: OriginalX * 50 + MarginX + public int Y { get; set; } + public float OriginalX { get; set; } + public float OriginalY { get; set; } + public float OriginalZ { get; set; } + public bool Inner { get; set; } // non-integer Number +} + +/// Loads the 8 CSVs from JacketData/ — front|back|right|left x L|XL. +public class JacketData +{ + public static string AppFile(string relative) { return relative; } + public const int CoordScale = 50; // Form2: originalX * 50 + public const int MarginX = 30; + public const int MarginY = 30; + + private const string ConfigFile = "config.txt"; + private const string DefaultDir = "JacketData"; + + private static readonly string[] Views = { "front", "back", "right", "left" }; + private static readonly string[] Sizes = { "L", "XL" }; + + private readonly Dictionary> _l = new Dictionary>(); + private readonly Dictionary> _xl = new Dictionary>(); + + public string Folder { get; private set; } + public bool Loaded { get; private set; } + + /// Form2.GetCurrentCSVData() + public List Get(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + List list; + return dict.TryGetValue(view, out list) ? list : new List(); + } + + public bool Has(string size, string view) + { + var dict = (size == "XL") ? _xl : _l; + return dict.ContainsKey(view) && dict[view].Count > 0; + } + + // ── folder resolution: config.txt, else JacketData/ next to the exe ── + + public static string ResolveFolder() + { + string cfg = ConfigFile; + + if (File.Exists(cfg)) + { + try + { + string saved = File.ReadAllText(cfg).Trim(); + if (Directory.Exists(saved) && Validate(saved)) + { + Logger.Write("jacket", "folder from config.txt: " + saved); + return saved; + } + + Logger.Write("jacket", "config.txt path invalid, ignoring: " + saved); + } + catch (Exception ex) + { + Logger.Write("jacket", "config.txt unreadable: " + ex.Message); + } + } + + // return SoundWindow.AppFile(DefaultDir); + return DefaultDir; + } + + public static void SaveFolder(string folder) + { + try { File.WriteAllText(SoundWindow.AppFile(ConfigFile), folder); } + catch (Exception ex) { Logger.Write("jacket", "could not save config.txt: " + ex.Message); } + } + + /// Form1.ValidateCSVFiles — all 8 must be present. + public static bool Validate(string folder) + { + foreach (string v in Views) + foreach (string s in Sizes) + if (!File.Exists(Path.Combine(folder, v + "_" + s + ".csv"))) + return false; + return true; + } + + public static string[] MissingFiles(string folder) + { + var missing = new List(); + foreach (string v in Views) + foreach (string s in Sizes) + { + string name = v + "_" + s + ".csv"; + if (!File.Exists(Path.Combine(folder, name))) missing.Add(name); + } + return missing.ToArray(); + } + + // ── loading ───────────────────────────────────────────────────────── + + public static JacketData Load() + { + return Load(ResolveFolder()); + } + + public static JacketData Load(string folder) + { + var data = new JacketData(); + data.Folder = folder; + + if (!Directory.Exists(folder)) + { + Logger.Write("jacket", "folder not found: " + folder); + return data; + } + + string[] missing = MissingFiles(folder); + if (missing.Length > 0) + Logger.Write("jacket", "missing " + missing.Length + " file(s): " + string.Join(", ", missing)); + + foreach (string view in Views) + { + foreach (string size in Sizes) + { + string path = Path.Combine(folder, view + "_" + size + ".csv"); + var circles = ReadCsv(path); + if (circles.Count == 0) continue; + + if (size == "XL") data._xl[view] = circles; + else data._l[view] = circles; + + Logger.Write("jacket", "loaded " + circles.Count + " chips from " + view + "_" + size + ".csv"); + } + } + + data.Loaded = (data._l.Count > 0 || data._xl.Count > 0); + if (!data.Loaded) Logger.Write("jacket", "NO chip data loaded from " + folder); + + return data; + } + + /// Columns: Number, OriginalX, OriginalY, OriginalZ. Header optional. + private static List ReadCsv(string path) + { + var circles = new List(); + + if (!File.Exists(path)) + { + Logger.Write("jacket", "file not found: " + path); + return circles; + } + + string[] lines; + try { lines = File.ReadAllLines(path); } + catch (Exception ex) + { + Logger.Write("jacket", "read failed " + path + ": " + ex.Message); + return circles; + } + + int start = 0; + if (lines.Length > 0) + { + string h = lines[0].ToLower(); + if (h.Contains("number") || h.Contains("originalx")) start = 1; + } + + for (int i = start; i < lines.Length; i++) + { + string line = lines[i].Trim(); + if (line.Length == 0) continue; + + string[] p = line.Split(','); + if (p.Length < 4) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": only " + p.Length + " columns"); + continue; + } + + float number, ox, oy, oz; + var inv = CultureInfo.InvariantCulture; + + // InvariantCulture matters — under ja_JP/de_DE, "1.5" can fail to parse. + if (!float.TryParse(p[0].Trim(), NumberStyles.Float, inv, out number) || + !float.TryParse(p[1].Trim(), NumberStyles.Float, inv, out ox) || + !float.TryParse(p[2].Trim(), NumberStyles.Float, inv, out oy) || + !float.TryParse(p[3].Trim(), NumberStyles.Float, inv, out oz)) + { + Logger.Write("jacket", path + " line " + (i + 1) + ": bad number format"); + continue; + } + + circles.Add(new Circle + { + Number = number, + X = (int)(ox * CoordScale) + MarginX, + Y = (int)(oy * CoordScale) + MarginY, + OriginalX = ox, + OriginalY = oy, + OriginalZ = oz, + Inner = (number % 1 != 0) + }); + } + + return circles; + } +} \ No newline at end of file diff --git a/programdeltafixer/Logger.cs b/programdeltafixer/Logger.cs new file mode 100644 index 0000000..85b0efe --- /dev/null +++ b/programdeltafixer/Logger.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Text; + +/// Append-only text log next to the .exe: logs/ears-YYYYMMDD.log +public static class Logger +{ + private static readonly object _gate = new object(); + private static string _path; + private static bool _failed; + + public static void Write(string message) + { + Write(null, message); + } + + public static void Write(string tag, string message) + { + if (_failed) return; + + string line = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff") + + (string.IsNullOrEmpty(tag) ? " " : " [" + tag + "] ") + + message; + + lock (_gate) + { + try + { + if (_path == null) _path = Init(); + File.AppendAllText(_path, line + Environment.NewLine, Encoding.UTF8); + } + catch (Exception ex) + { + _failed = true; // never let logging crash the app + Console.WriteLine("[Logger] disabled: " + ex.Message); + } + } + } + + public static void Write(string tag, string format, params object[] args) + { + Write(tag, string.Format(format, args)); + } + + public static void Exception(string tag, Exception ex) + { + Write(tag, ex.GetType().Name + ": " + ex.Message); + Write(tag, ex.StackTrace ?? "(no stack)"); + } + + private static string Init() + { + string dir = Path.Combine( + Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), + "logs"); + + Directory.CreateDirectory(dir); + + string path = Path.Combine(dir, "ears-" + DateTime.Now.ToString("yyyyMMdd") + ".log"); + + File.AppendAllText(path, + Environment.NewLine + + "=== session start " + DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + + " (" + Environment.OSVersion.Platform + ") ===" + Environment.NewLine, + Encoding.UTF8); + + return path; + } +} \ No newline at end of file diff --git a/programdeltafixer/SoundLooper.cs b/programdeltafixer/SoundLooper.cs new file mode 100644 index 0000000..311d4b6 --- /dev/null +++ b/programdeltafixer/SoundLooper.cs @@ -0,0 +1,165 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Media; +using System.Threading; + +public class SoundLooper : IDisposable +{ + // ── unix backend ── + private Process _proc; + private Thread _thread; + private volatile bool _running; + private string _path; + + // ── windows backend ── + private SoundPlayer _player; + + public event Action Log; + + public bool IsPlaying { get { return _running; } } + + private static bool IsWindows + { + get + { + var p = Environment.OSVersion.Platform; + return p != PlatformID.Unix && p != PlatformID.MacOSX; + } + } + + public void Start(string wavPath) + { + Stop(); + + if (string.IsNullOrEmpty(wavPath) || !File.Exists(wavPath)) + { + Emit("Sound file not found: " + wavPath); + return; + } + + _path = wavPath; + + if (IsWindows) StartWindows(); + else StartUnix(); + } + + public void Stop() + { + if (!_running) return; + _running = false; + + if (_player != null) + { + try { _player.Stop(); } catch { } + try { _player.Dispose(); } catch { } + _player = null; + } + + try + { + var p = _proc; + if (p != null && !p.HasExited) p.Kill(); + } + catch { } + _proc = null; + + if (_thread != null && _thread.IsAlive) _thread.Join(500); + _thread = null; + + Emit("Looping stopped"); + } + + // ── windows: SoundPlayer loops natively, no thread needed ────────── + + private void StartWindows() + { + try + { + _player = new SoundPlayer(_path); + _player.Load(); // throws here if the WAV isn't plain PCM + _player.PlayLooping(); // gapless, runs until Stop() + _running = true; + Emit("Looping started (SoundPlayer): " + _path); + } + catch (Exception ex) + { + Emit("SoundPlayer failed for " + _path + ": " + ex.Message); + _player = null; + _running = false; + } + } + + // ── unix: respawn a CLI player each pass ─────────────────────────── + + private void StartUnix() + { + _running = true; + _thread = new Thread(LoopWorker); + _thread.IsBackground = true; + _thread.Start(); + Emit("Looping started (" + PlayerCommand() + "): " + _path); + } + + private void LoopWorker() + { + while (_running) + { + try + { + var psi = new ProcessStartInfo + { + FileName = PlayerCommand(), + Arguments = "\"" + _path + "\"", + UseShellExecute = false, + RedirectStandardError = true, + RedirectStandardOutput = true, + CreateNoWindow = true + }; + + _proc = Process.Start(psi); + _proc.WaitForExit(); + } + catch (Exception ex) + { + Emit("Playback error: " + ex.Message); + _running = false; + return; + } + } + } + + private static string _player_cmd; + private static string PlayerCommand() + { + if (_player_cmd != null) return _player_cmd; + _player_cmd = Exists("paplay") ? "paplay" : "aplay"; + return _player_cmd; + } + + private static bool Exists(string cmd) + { + try + { + var p = Process.Start(new ProcessStartInfo + { + FileName = "which", + Arguments = cmd, + UseShellExecute = false, + RedirectStandardOutput = true + }); + p.WaitForExit(); + return p.ExitCode == 0; + } + catch { return false; } + } + + private void Emit(string msg) + { + var h = Log; + if (h != null) h(msg); + else Console.WriteLine("[SoundLooper] " + msg); + } + + public void Dispose() { Stop(); } +} \ No newline at end of file diff --git a/programdeltafixer/SoundWindow.cs b/programdeltafixer/SoundWindow.cs new file mode 100644 index 0000000..e8b4488 --- /dev/null +++ b/programdeltafixer/SoundWindow.cs @@ -0,0 +1,162 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +/// Controller for notebook page 2 (SoundBox). Not a Gtk.Window — same +/// arrangement as SettingsWindow: it drives widgets inside the shared root. +public class SoundWindow +{ + [UI] private Label ConditionNameLabel = null; + [UI] private Grid SoundButtonGrid = null; + [UI] private Button SoundBackButton = null; + + private const int Columns = 2; + + private readonly List _allCases; + private readonly List _path = new List(); + + /// Raised when the drill-down reaches a leaf case. + public event EventHandler CaseSelected; + + /// Raised when Back is pressed at the top level. + public event EventHandler BackRequested; + + public SoundWindow(Builder builder) + { + 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)); + + _allCases = CaseDefinition.LoadCasesFromCsv(AppFile("cases.csv")); + if (_allCases.Count == 0) + Console.WriteLine("WARNING: no cases loaded — check cases.csv"); + + SoundBackButton.Clicked += OnBackClicked; + } + + /// Call every time the page becomes visible. + public void Reset() + { + _path.Clear(); + Render(); + } + + /// Resolve next to the .exe, NOT the working directory. + public static string AppFile(string relative) + { + string dir = Path.GetDirectoryName( + System.Reflection.Assembly.GetExecutingAssembly().Location); + return Path.Combine(dir, relative); + } + + // ── one method covers all three levels ───────────────────────────── + + private void Render() + { + var matches = _allCases.Where(MatchesPath).ToList(); + + // Distinct values one level deeper than where we currently are. + 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); + + // Landed on a leaf? Play it and stay put. + var leaf = _allCases.FirstOrDefault( + c => c.TreePath.Length == _path.Count && MatchesPath(c)); + + if (leaf != null) + { + Play(leaf); + _path.RemoveAt(_path.Count - 1); + return; + } + + Render(); + }); + } + + private bool MatchesPath(CaseDefinition c) + { + string[] p = c.TreePath; + if (p.Length < _path.Count) return false; + for (int i = 0; i < _path.Count; i++) + if (!string.Equals(p[i], _path[i], StringComparison.Ordinal)) return false; + return true; + } + + private void BuildButtons(IEnumerable labels, Action onPick) + { + foreach (var child in SoundButtonGrid.Children) + { + SoundButtonGrid.Remove(child); + child.Destroy(); + } + + int i = 0; + foreach (string text in labels) + { + string captured = text; // don't close over the loop variable + var btn = new Button(captured); + btn.Hexpand = true; + btn.Clicked += (s, e) => onPick(captured); + SoundButtonGrid.Attach(btn, i % Columns, i / Columns, 1, 1); + i++; + } + + SoundButtonGrid.ShowAll(); // widgets made in code start hidden + } + + private void Play(CaseDefinition c) + { + if (string.IsNullOrWhiteSpace(c.SoundPath)) + { + Console.WriteLine("No Sound_File for case " + c.Number); + return; + } + + WavePlayer.Stop(); // the map page owns audio from here on + + var h = CaseSelected; + if (h != null) h(this, c); + + // string full = AppFile(c.SoundPath); // "sound/SND200.wav" -> absolute + // if (!File.Exists(full)) + // { + // Console.WriteLine("Sound file missing: " + full); + // return; + // } + + // ConditionNameLabel.Text = string.Join(" : ", _path); + // WavePlayer.Play(full); + } + + private void OnBackClicked(object sender, EventArgs e) + { + if (_path.Count > 0) + { + _path.RemoveAt(_path.Count - 1); + Render(); + } + else + { + WavePlayer.Stop(); + if (BackRequested != null) BackRequested(this, EventArgs.Empty); + } + } +} \ No newline at end of file diff --git a/programdeltafixer/WavePlayer.cs b/programdeltafixer/WavePlayer.cs new file mode 100644 index 0000000..00cd07a --- /dev/null +++ b/programdeltafixer/WavePlayer.cs @@ -0,0 +1,60 @@ +using System; +using System.Diagnostics; +using System.IO; + +public static class WavePlayer +{ + private static Process _current; + + private static bool IsUnix + { + get + { + int p = (int)Environment.OSVersion.Platform; + return p == 4 || p == 6 || p == 128; + } + } + + public static void Play(string absolutePath) + { + Stop(); + + if (!IsUnix) + { + try + { + var sp = new System.Media.SoundPlayer(absolutePath); + sp.Play(); + } + catch (Exception ex) { Console.WriteLine("Playback failed: " + ex.Message); } + return; + } + + foreach (string player in new[] { "paplay", "aplay" }) + { + try + { + var psi = new ProcessStartInfo(player, "\"" + absolutePath + "\"") + { + UseShellExecute = false, + RedirectStandardError = true + }; + _current = Process.Start(psi); + return; + } + catch { /* not installed, try the next one */ } + } + + Console.WriteLine("No audio player found — install pulseaudio-utils or alsa-utils"); + } + + public static void Stop() + { + try + { + if (_current != null && !_current.HasExited) _current.Kill(); + } + catch { } + _current = null; + } +} \ No newline at end of file diff --git a/programdeltafixer/calibrate.glade b/programdeltafixer/calibrate.glade new file mode 100644 index 0000000..66eccc6 --- /dev/null +++ b/programdeltafixer/calibrate.glade @@ -0,0 +1,144 @@ + + + + + False + EARS — Calibration + 1280 + 800 + + + True + horizontal + 10 + + + True + True + 380 + never + + + True + + + True + vertical + 6 + 10 + 10 + 10 + 10 + + + + + + + False + True + 0 + + + + + True + vertical + 6 + True + 10 + 10 + 10 + + + True + True + True + + + True + True + 0 + + + + + True + 6 + + + Dump C# constants + True + True + True + + + True + True + 0 + + + + + Save calib.txt + True + True + True + + + True + True + 1 + + + + + Reset view + True + True + True + + + False + True + 2 + + + + + False + True + 1 + + + + + True + True + 170 + in + + + True + False + True + + + + + False + True + 2 + + + + + True + True + 1 + + + + + + \ No newline at end of file diff --git a/programdeltafixer/program.cs b/programdeltafixer/program.cs new file mode 100644 index 0000000..46b4c1a --- /dev/null +++ b/programdeltafixer/program.cs @@ -0,0 +1,23 @@ +using System; +using System.IO; +using System.Reflection; +using Gtk; + +class Program +{ + [STAThread] + static void Main(string[] args) + { + // Resolve every relative path (calibrate.glade, map/, cases.csv, + // JacketData/) against the exe's folder, not the launch directory. + Directory.SetCurrentDirectory( + Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)); + + Application.Init(); + + var app = new CalibrationWindow(); + app.ShowAll(); + + Application.Run(); + } +} \ No newline at end of file