using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Gtk;
using UI = Gtk.Builder.ObjectAttribute;
using System.Runtime.InteropServices;
using System.Diagnostics;
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
[UI] private Label ConnectionAlertLabel = null;
[UI] private DrawingArea BatteryArea = null;
private uint _retryTimerId;
private bool _probing;
private const uint RetryMs = 1000;
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<string, string> _engText;
// private Dictionary<string, string> _jpnText;
// private Dictionary<string, Button> _buttonsById;
private bool _connected;
private readonly ArduinoConnection _arduino = new ArduinoConnection();
internal SettingsWindow _settingsWindow;
internal Language _Language;
internal SoundWindow _soundWindow;
internal MapWindow _mapWindow;
// Battery
private BatteryStatus _battery = new BatteryStatus();
private uint _batteryTimerId;
private const uint BatteryPollMs = 10000;
public MainWindow() : this(CreateBuilder()) { }
private static Builder CreateBuilder()
{
var builder = new Builder();
builder.AddFromFile("homepageallv8.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<string, Button>
// // {
// // { "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);
_Language.RegisterDynamic("BatteryArea", BatteryArea);
if (BatteryArea != null) BatteryArea.Drawn += OnBatteryDrawn;
ConnectionAlertLabel.Name = "ConnectionAlertLabel";
_Language.Register("ConnectionAlertLabel", ConnectionAlertLabel);
_settingsWindow = new SettingsWindow(builder, _arduino, _Language);
_settingsWindow.BackRequested += OnSettingsClosed;
_mapWindow = new MapWindow(builder, _arduino, _Language);
_mapWindow.BackRequested += OnMapClosed;
_Language.Changed += (s, e) => {
OnArduinoConnectionChanged(_connected);
UpdateBattery();
};
_Language.Apply(LanguageToggle.Active);
UpdateBattery();
_batteryTimerId = GLib.Timeout.Add(BatteryPollMs, OnBatteryTick);
// // 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);
StartAutoDetectOnLoad();
if (AppConfig.DebugMode)
this.Title = (this.Title ?? "App") + " [DEBUG MODE]";
}
// ---------- 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 async void StartAutoDetectOnLoad()
{
// Lock down splash options until we know the device state.
OnArduinoConnectionChanged(false);
string found = await _arduino.DetectPortAsync();
if (found != null)
_arduino.Connect(found); // fires ConnectionChanged(true) -> unlocks options
else
OnArduinoConnectionChanged(false); // still no device -> stays locked unless debug
}
private void OnArduinoConnectionChanged(bool connected)
{
_connected = connected;
// Debug mode bypasses the "device required" restriction entirely.
bool allowFullAccess = connected || AppConfig.DebugMode;
Option1Button.Sensitive = allowFullAccess;
Option2Button.Sensitive = allowFullAccess;
Option3Button.Sensitive = allowFullAccess;
// SettingButton.Sensitive = true; // always reachable, device or not
if (ConnectionAlertLabel != null)
{
ConnectionAlertLabel.Visible = !connected;
var ctx = ConnectionAlertLabel.StyleContext;
if (connected) ctx.RemoveClass("alert");
else ctx.AddClass("alert");
}
if (connected || AppConfig.DebugMode) StopAutoRetry();
else StartAutoRetry();
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"];
}
private void StartAutoRetry()
{
if (_retryTimerId != 0) return;
_retryTimerId = GLib.Timeout.Add(RetryMs, OnRetryTick);
}
private void StopAutoRetry()
{
if (_retryTimerId != 0)
{
GLib.Source.Remove(_retryTimerId);
_retryTimerId = 0;
}
}
private bool OnRetryTick()
{
if (_arduino.IsOpen) { _retryTimerId = 0; return false; }
if (_probing) return true; // scan still running — don't stack them
_probing = true;
_arduino.DetectPortAsync().ContinueWith(t =>
{
string port = t.IsFaulted ? null : t.Result;
// DetectPortAsync runs off the UI thread; Connect() touches GLib.
Gtk.Application.Invoke((s, e) =>
{
_probing = false;
if (port != null && !_arduino.IsOpen)
_arduino.Connect(port); // fires ConnectionChanged(true)
});
});
return true;
}
private bool OnBatteryTick()
{
UpdateBattery();
return true; // keep polling
}
private void UpdateBattery()
{
if (BatteryArea == null) return;
_battery = BatteryMonitor.Read();
if (!_battery.Present) // desktop / dev machine — hide it entirely
{
BatteryArea.Visible = false;
return;
}
string key = _battery.Full ? "BatteryIcon.Full"
: _battery.Charging ? "BatteryIcon.Charging"
: "BatteryIcon.Discharging";
BatteryArea.Visible = true;
BatteryArea.TooltipText = _Language.Format(key, _battery.Percent);
BatteryArea.QueueDraw();
}
private void OnBatteryDrawn(object o, DrawnArgs args)
{
DrawingArea area = (DrawingArea)o;
Cairo.Context cr = args.Cr;
double w = area.AllocatedWidth;
double h = area.AllocatedHeight;
Gdk.RGBA fg = area.StyleContext.GetColor(StateFlags.Normal);
double stroke = Math.Max(1.5, h * 0.07);
double nub = Math.Max(2.0, w * 0.055);
double gap = Math.Max(1.0, w * 0.02);
double bw = w - nub - gap - stroke;
double bh = Math.Min(h - stroke, bw * 0.52);
double bx = stroke / 2;
double by = (h - bh) / 2;
double r = bh * 0.20;
// shell
cr.LineWidth = stroke;
cr.SetSourceRGBA(fg.Red, fg.Green, fg.Blue, fg.Alpha);
RoundedRect(cr, bx, by, bw, bh, r);
cr.Stroke();
// terminal
double th = bh * 0.42;
cr.Rectangle(bx + bw + gap, (h - th) / 2, nub, th);
cr.Fill();
// level fill
double pad = stroke * 1.4;
double ix = bx + pad, iy = by + pad;
double iw = bw - 2 * pad, ih = bh - 2 * pad;
double pct = Math.Max(0, Math.Min(100, _battery.Percent));
double fillW = iw * (pct / 100.0);
if (fillW > 0.5)
{
double fr, fgc, fb;
if (!_battery.Charging && pct <= 10) { fr = 0.78; fgc = 0.16; fb = 0.16; }
else if (!_battery.Charging && pct <= 25) { fr = 0.93; fgc = 0.62; fb = 0.09; }
else { fr = 0.18; fgc = 0.65; fb = 0.31; }
cr.Save();
RoundedRect(cr, ix, iy, iw, ih, Math.Max(0, r - pad * 0.5));
cr.Clip();
cr.NewPath();
cr.Rectangle(ix, iy, fillW, ih);
cr.SetSourceRGB(fr, fgc, fb);
cr.Fill();
cr.Restore();
}
// contents: optional charging bolt + the number
string text = _battery.Present
? _battery.Percent.ToString(CultureInfo.InvariantCulture)
: "--";
cr.SelectFontFace("Sans", Cairo.FontSlant.Normal, Cairo.FontWeight.Bold);
cr.SetFontSize(bh * 0.60);
Cairo.TextExtents te = cr.TextExtents(text);
double boltW = _battery.Charging ? bh * 0.36 : 0;
double boltG = _battery.Charging ? bh * 0.14 : 0;
double startX = bx + (bw - (boltW + boltG + te.Width)) / 2;
double tx = startX + boltW + boltG - te.XBearing;
double ty = by + (bh - te.Height) / 2 - te.YBearing;
System.Action paint = () =>
{
if (_battery.Charging)
DrawBolt(cr, startX, by + bh * 0.16, boltW, bh * 0.68);
cr.MoveTo(tx, ty);
cr.ShowText(text);
};
cr.Save(); // over the fill -> light
cr.Rectangle(ix, 0, fillW, h);
cr.Clip();
cr.SetSourceRGB(1, 1, 1);
paint();
cr.Restore();
cr.Save(); // over the empty part -> theme fg
cr.Rectangle(ix + fillW, 0, w, h);
cr.Clip();
cr.SetSourceRGBA(fg.Red, fg.Green, fg.Blue, fg.Alpha);
paint();
cr.Restore();
}
private static void RoundedRect(Cairo.Context cr, double x, double y,
double w, double h, double r)
{
cr.NewPath();
cr.Arc(x + w - r, y + r, r, -Math.PI / 2, 0);
cr.Arc(x + w - r, y + h - r, r, 0, Math.PI / 2);
cr.Arc(x + r, y + h - r, r, Math.PI / 2, Math.PI);
cr.Arc(x + r, y + r, r, Math.PI, 3 * Math.PI / 2);
cr.ClosePath();
}
private static void DrawBolt(Cairo.Context cr, double x, double y,
double w, double h)
{
cr.NewPath();
cr.MoveTo(x + w * 0.58, y);
cr.LineTo(x, y + h * 0.58);
cr.LineTo(x + w * 0.42, y + h * 0.58);
cr.LineTo(x + w * 0.32, y + h);
cr.LineTo(x + w, y + h * 0.40);
cr.LineTo(x + w * 0.56, y + h * 0.40);
cr.ClosePath();
cr.Fill();
}
private static string PickBatteryIcon(BatteryStatus b)
{
string level;
if (b.Percent >= 80) level = "full";
else if (b.Percent >= 50) level = "good";
else if (b.Percent >= 20) level = "low";
else if (b.Percent >= 10) level = "caution";
else level = "empty";
string name = b.Full ? "battery-full-charged"
: b.Charging ? "battery-" + level + "-charging"
: "battery-" + level;
return FirstAvailableIcon(name + "-symbolic", name, "battery");
}
private static string FirstAvailableIcon(params string[] names)
{
IconTheme theme = IconTheme.Default;
if (theme != null)
foreach (string n in names)
if (theme.HasIcon(n)) return n;
return names[names.Length - 1];
}
// ---------- localisation ----------
// private Dictionary<string, string> LoadButtonText(string path)
// {
// var result = new Dictionary<string, string>();
// 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)
{
if (KioskMode && !ConfirmPowerOff()) return;
Shutdown();
}
private void Shutdown()
{
// _arduino.Disconnect();
// _arduino.Dispose();
// // _settingsWindow?.Destroy();
// Application.Quit();
// Release hardware before the OS starts killing things.
if (_mapWindow != null) _mapWindow.StopSession();
WavePlayer.Stop();
StopAutoRetry();
if (_batteryTimerId != 0) { GLib.Source.Remove(_batteryTimerId); _batteryTimerId = 0; }
_arduino.Disconnect();
_arduino.Dispose();
if (!KioskMode) { Application.Quit(); return; }
// CloseButton is the only label MainWindow itself owns on the splash page.
CloseButton.Label = _Language["Close.ShuttingDown"];
CloseButton.Sensitive = false;
Option1Button.Sensitive = false;
Option2Button.Sensitive = false;
Option3Button.Sensitive = false;
SettingButton.Sensitive = false;
// Return to the main loop so that repaint actually happens, then fire.
GLib.Timeout.Add(200, new GLib.TimeoutHandler(OnPowerOffTick));
}
private bool OnPowerOffTick()
{
if (!RequestPowerOff())
{
Console.WriteLine("poweroff refused - quitting instead");
Application.Quit();
}
return false; // one shot
}
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
}
// ---------- power ----------
// Same master switch the boot scripts read. On a dev box the flag is absent,
// so Close just exits the app instead of killing your machine.
private static bool KioskMode
{
get
{
// string home = System.Environment.GetEnvironmentVariable("HOME");
// if (string.IsNullOrEmpty(home)) return false;
// return System.IO.File.Exists(
// System.IO.Path.Combine(home, "kiosk/enabled"));
try
{
string cmdline = System.IO.File.ReadAllText("/proc/cmdline");
foreach (string arg in cmdline.Split(new[] { ' ', '\n' },
StringSplitOptions.RemoveEmptyEntries))
if (arg == "kiosk") return true;
}
catch { }
return false;
}
}
private bool ConfirmPowerOff()
{
var dlg = new Dialog();
dlg.Name = "PowerOffDialog";
dlg.TransientFor = this;
dlg.Modal = true;
dlg.Decorated = false; // matchbox gives no useful frame
dlg.TypeHint = Gdk.WindowTypeHint.Dialog;
dlg.WindowPosition = WindowPosition.CenterOnParent;
dlg.SetDefaultSize(780, 320);
var msg = new Label(_Language["Close.ConfirmPowerOff"]);
msg.Name = "PowerOffMessage";
msg.LineWrap = true;
msg.LineWrapMode = Pango.WrapMode.WordChar;
msg.Justify = Justification.Center;
Box content = dlg.ContentArea;
content.Spacing = 0;
content.PackStart(msg, true, true, 0);
var cancel = (Button)dlg.AddButton(_Language["Common.Cancel"], (int)ResponseType.Cancel);
var ok = (Button)dlg.AddButton(_Language["Common.PowerOff"], (int)ResponseType.Ok);
cancel.Name = "PowerOffCancel";
ok.Name = "PowerOffConfirm";
dlg.DefaultResponse = ResponseType.Cancel;
dlg.ShowAll();
ResponseType r = (ResponseType)dlg.Run();
dlg.Destroy();
return r == ResponseType.Ok;
}
private static bool RequestPowerOff()
{
if (Spawn("systemctl", "poweroff")) return true; // logind + polkit
if (Spawn("sudo", "-n /sbin/poweroff")) return true; // sudoers fallback
return false;
}
private static bool Spawn(string exe, string args)
{
try
{
var psi = new System.Diagnostics.ProcessStartInfo(exe, args);
psi.UseShellExecute = false;
var proc = System.Diagnostics.Process.Start(psi);
if (proc == null) return false;
if (!proc.WaitForExit(5000)) return true; // still running = request accepted
return proc.ExitCode == 0;
}
catch { return false; }
}
}