diff --git a/OS/.gitkeep b/OS/.gitkeep new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/OS/.gitkeep diff --git a/Readme.md b/Readme.md index 402d96e..51512ad 100644 --- a/Readme.md +++ b/Readme.md @@ -1,20 +1,28 @@ # 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) + --- -## 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 +34,7 @@ --- -## 2. Install Mono +### 2. Install Mono ```bash sudo apt update @@ -41,7 +49,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 +67,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 +81,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 +91,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 +114,9 @@ --- -## 7. Minimal Working Examples +### 7. Minimal Working Examples -### Hello World (console) +#### Hello World (console) ```csharp using System; @@ -123,7 +131,7 @@ mono hello.exe ``` -### Hello World (GTK# window, no Glade) +#### Hello World (GTK# window, no Glade) ```csharp using System; using Gtk; @@ -149,7 +157,7 @@ mono gtkcheck.exe ``` -### Loading a UI built in Glade +#### Loading a UI built in Glade ```csharp using System; using Gtk; @@ -175,11 +183,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 +196,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 +210,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 +221,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 +261,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 +271,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 +286,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 +316,7 @@ --- -## 9. Common Errors & Fixes +### 9. Common Errors & Fixes | Error | Cause | Fix | |---|---|---| @@ -322,7 +330,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 +361,22 @@ 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 \ No newline at end of file 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/Language.cs b/program/Language.cs new file mode 100644 index 0000000..70695ae --- /dev/null +++ b/program/Language.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.IO; +using Gtk; + +public class Language +{ + public static bool LanguageSwitchValue = false; + + 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 readonly Dictionary _buttonsById; + public readonly CssProvider _languageCssProvider = new CssProvider(); + private readonly Label _languageLabel; + + public Language(Dictionary buttonsById, Label languageLabel) + { + _buttonsById = buttonsById; + _languageLabel = languageLabel; + StyleContext.AddProviderForScreen(Gdk.Screen.Default, _languageCssProvider, 600); + + + _engText = LoadButtonText(EngCsvPath); + _jpnText = LoadButtonText(JpnCsvPath); + } + + 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++) + { + 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; + } + + public 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"; + } + + //for others + public Language(Dictionary buttonsById) + { + _buttonsById = buttonsById; + StyleContext.AddProviderForScreen(Gdk.Screen.Default, _languageCssProvider, 600); + + + _engText = LoadButtonText(EngCsvPath); + _jpnText = LoadButtonText(JpnCsvPath); + } + + + public void ApplyLanguages(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}"); + + } +} \ No newline at end of file diff --git a/program/MainWindow.cs b/program/MainWindow.cs new file mode 100644 index 0000000..0af3392 --- /dev/null +++ b/program/MainWindow.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using Gtk; +using UI = Gtk.Builder.ObjectAttribute; + +public class MainWindow : Window +{ + [UI] private Button Option1Button = null; + [UI] private Button Option2Button = null; + [UI] private Button Option3Button = null; + [UI] private Switch LanguageSwitch = 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 readonly ArduinoConnection _arduino = new ArduinoConnection(); + internal SettingsWindow _settingsWindow; + internal Language _Language; + + public MainWindow() : this(CreateBuilder()) { } + + private static Builder CreateBuilder() + { + var builder = new Builder(); + builder.AddFromFile("homepage.glade"); + return builder; + } + + private MainWindow(Builder builder) : base(builder.GetObject("SplashWindow").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); + + this.Fullscreen(); + + Option1Button.Clicked += OnNormalSessionClicked; + Option2Button.Clicked += OnPositionRecordingClicked; + Option3Button.Clicked += OnExamModeClicked; + CloseButton.Clicked += OnCloseClicked; + SettingButton.Clicked += OnSettingClicked; + LanguageSwitch.AddNotification("active", OnLanguageSwitchNotify); + + 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); + 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(); + } + + + _settingsWindow.ShowAll(); + _settingsWindow.Present(); + this.Hide(); + } + + private void OnSettingsClosed(object sender, EventArgs e) + { + this.ShowAll(); + this.Present(); + _settingsWindow.Hide(); + } + + private void OnArduinoConnectionChanged(bool connected) + { + if (ConnectionIcon == null) return; + + ConnectionIcon.SetFromIconName( + connected ? "network-transmit-receive" : "network-offline", + IconSize.Dnd); + + ConnectionIcon.TooltipText = connected + ? $"Connected to {_arduino.PortName}" + : "Device not connected"; + } + + // ---------- 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) + { + 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"); + } + + 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 OnLanguageSwitchNotify(object o, GLib.NotifyArgs args) + { + Language.LanguageSwitchValue = LanguageSwitch.Active; + _Language.ApplyLanguage(LanguageSwitch.Active); + _settingsWindow?._Language?.ApplyLanguages(LanguageSwitch.Active); + } +} \ No newline at end of file diff --git a/program/SettingWindow.cs b/program/SettingWindow.cs new file mode 100644 index 0000000..2d94a53 --- /dev/null +++ b/program/SettingWindow.cs @@ -0,0 +1,210 @@ +using System; +using Gtk; +using System.Collections.Generic; +using UI = Gtk.Builder.ObjectAttribute; + +public class SettingsWindow : Window +{ + [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; + + private readonly Dictionary _buttonsById; + private readonly Dictionary _labelsById; + internal Language _Language; + + 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(ArduinoConnection arduino) + : this(CreateBuilder(), arduino) { } + + private static Builder CreateBuilder() + { + var builder = new Builder(); + builder.AddFromFile("homepage.glade"); + return builder; + } + + private SettingsWindow(Builder builder, ArduinoConnection arduino) + : base(builder.GetObject("SettingWindow").Handle) + { + builder.Autoconnect(this); + _arduino = arduino; + + 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; + BackButton.Clicked += OnBackClicked; + + DeleteEvent += (o, a) => { a.RetVal = true; OnBackClicked(o, EventArgs.Empty); }; + + _arduino.Log += AppendLog; + _arduino.LineReceived += OnLineReceived; + _arduino.ConnectionChanged += OnConnectionChanged; + + _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($"{_ports.Length} port(s) found"); + } + else + { + AppendLog("No serial ports found"); + } + } + + private void OnConnectClicked(object sender, EventArgs e) + { + if (_arduino.IsOpen) + { + _arduino.Disconnect(); + return; + } + + string port = ComPortComboBox.ActiveText; + if (string.IsNullOrEmpty(port)) + { + AppendLog("Select a COM port first"); + return; + } + _arduino.Connect(port); + } + + private async void OnAutoConnectClicked(object sender, EventArgs e) + { + AutoConnectButton.Sensitive = false; + ConnectButton.Sensitive = false; + AppendLog("Scanning ports for device..."); + + string found = await _arduino.DetectPortAsync(); + + if (found != null) + { + RefreshPorts(); + SelectPort(found); + _arduino.Connect(found); + } + else + { + AppendLog("Device not found — use manual connection"); + } + + 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) + { + ConnectButton.Label = connected ? "Disconnect" : "Connect"; + StatusLabel.Text = connected + ? $"Connected ({_arduino.PortName})" + : "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("[ESP-NOW] Peer connected"); return; } + if (line.Contains("ESPNOW_DISCONNECTED")) { AppendLog("[ESP-NOW] Peer disconnected"); 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/homepage.glade b/program/homepage.glade new file mode 100644 index 0000000..5b7939a --- /dev/null +++ b/program/homepage.glade @@ -0,0 +1,344 @@ + + + + + + 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 + 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..dcedc1b --- /dev/null +++ b/program/homepage.glade~ @@ -0,0 +1,326 @@ + + + + + + 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 + + + + + Refresh Ports + True + True + True + center + start + + + 0 + 1 + + + + + True + False + Disconnected + + + 2 + 1 + + + + + + + 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 + 6 + network-offline + 6 + + + 4 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/program/info_eng.csv b/program/info_eng.csv new file mode 100644 index 0000000..3a0481a --- /dev/null +++ b/program/info_eng.csv @@ -0,0 +1,11 @@ +ButtonId,Text +Option1Button,Normal Session +Option2Button,Position Recording Mode +Option3Button,Exam Mode +CloseButton,Close +SettingButton,Settings +ConnectButton,Connect +AutoConnectButton,Auto Connect +RefreshButton, Refresh Port +LogLabel,Log +BackButton,Back diff --git a/program/info_jpn.csv b/program/info_jpn.csv new file mode 100644 index 0000000..1d4e6e8 --- /dev/null +++ b/program/info_jpn.csv @@ -0,0 +1,11 @@ +ButtonId,Text +Option1Button,通常セッション +Option2Button,位置記録モード +Option3Button,試験モード +CloseButton,閉じる +SettingButton,設定 +ConnectButton,接続 +AutoConnectButton,自動接続 +RefreshButton, ポートの更新 +LogLabel,情報 +BackButton,戻る 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