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/SoundWindow.cs b/program/SoundWindow.cs new file mode 100644 index 0000000..98ae77b --- /dev/null +++ b/program/SoundWindow.cs @@ -0,0 +1,154 @@ +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 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; + } + + 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/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 + + + + + + + + +