Newer
Older
EARS-LINUX / program / MainWindow.cs
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("homepageallv7.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"));
        }
    }

    private bool ConfirmPowerOff()
    {
        var dlg = new MessageDialog(this,
            DialogFlags.Modal | DialogFlags.DestroyWithParent,
            MessageType.Question, ButtonsType.None,
            "{0}", _Language["Close.ConfirmPowerOff"]);

        dlg.AddButton(_Language["Common.Cancel"],   (int)ResponseType.Cancel);
        dlg.AddButton(_Language["Common.PowerOff"], (int)ResponseType.Ok);
        dlg.DefaultResponse = ResponseType.Cancel;

        var 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; }
    }


}