();
+ }
+
+ private void RememberExportFolder(string filePath)
+ {
+ try
+ {
+ var dir = Path.GetDirectoryName(filePath);
+ if (!string.IsNullOrEmpty(dir) && Directory.Exists(dir) && _settingsService != null)
+ {
+ _settingsService.Settings.LastExportFolder = dir;
+ _settingsService.Save();
+ }
+ }
+ catch { }
+ }
+
+ private void ExportResultsCsv()
+ {
+ try
+ {
+ if (_lastResults == null || _lastResults.Count == 0)
+ {
+ MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.",
+ _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ var t = _translationService;
+ string hCond = t?.T("ExportHeader_Condition") ?? "Condition";
+ string hScore = t?.T("ExportHeader_Score") ?? "Score";
+ string hMatches = t?.T("ExportHeader_Matches") ?? "Matches";
+ string hCategory = t?.T("ExportHeader_Category") ?? "Category";
+
+ string? initDir = _settingsService?.Settings.LastExportFolder;
+ var sfd = new SaveFileDialog { Filter = "CSV (*.csv)|*.csv", FileName = "results.csv", InitialDirectory = Directory.Exists(initDir) ? initDir : null };
+ if (sfd.ShowDialog(this) != DialogResult.OK) return;
+ RememberExportFolder(sfd.FileName);
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine(string.Join(",", new[] { EscapeCsv(hCond), EscapeCsv(hScore), EscapeCsv(hMatches), EscapeCsv(hCategory) }));
+
+ var rows = GetExportTargetMatches();
+ foreach (var m in rows)
+ {
+ string condDisp = _translationService?.Condition(m.Name) ?? m.Name;
+ string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
+ string catDisp = DetermineBestCategoryDisplay(m.Name);
+ sb.AppendLine(string.Join(",", new[]
+ {
+ EscapeCsv(condDisp),
+ EscapeCsv(scoreStr),
+ EscapeCsv(m.MatchCount.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ EscapeCsv(catDisp)
+ }));
+ }
+
+ File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void ExportResultsMarkdown()
+ {
+ try
+ {
+ if (_lastResults == null || _lastResults.Count == 0)
+ {
+ MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.",
+ _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ var t = _translationService;
+ string hCond = t?.T("ExportHeader_Condition") ?? "Condition";
+ string hScore = t?.T("ExportHeader_Score") ?? "Score";
+ string hMatches = t?.T("ExportHeader_Matches") ?? "Matches";
+ string hCategory = t?.T("ExportHeader_Category") ?? "Category";
+
+ string? initDir = _settingsService?.Settings.LastExportFolder;
+ var sfd = new SaveFileDialog { Filter = "Markdown (*.md)|*.md|Text (*.txt)|*.txt", FileName = "results.md", InitialDirectory = Directory.Exists(initDir) ? initDir : null };
+ if (sfd.ShowDialog(this) != DialogResult.OK) return;
+ RememberExportFolder(sfd.FileName);
+
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("# " + (t?.T("Title") ?? "Symptom Checker (Educational)"));
+ if (_checkedSymptoms.Count > 0)
+ {
+ sb.AppendLine();
+ sb.AppendLine("**" + (t?.T("SymptomsLabel") ?? "Symptoms:") + "** " + string.Join(", ", _checkedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)));
+ }
+ sb.AppendLine();
+ sb.AppendLine($"| {hCond} | {hScore} | {hMatches} | {hCategory} |");
+ sb.AppendLine("| --- | ---: | ---: | --- |");
+ var rows = GetExportTargetMatches();
+ foreach (var m in rows)
+ {
+ string condDisp = _translationService?.Condition(m.Name) ?? m.Name;
+ string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
+ string catDisp = DetermineBestCategoryDisplay(m.Name);
+ string matched = string.Join(", ", m.MatchedSymptoms.Select(s => _translationService?.Symptom(s) ?? s));
+ sb.AppendLine($"| {condDisp} | {scoreStr} | {m.MatchCount} | {catDisp} | ");
+ }
+
+ File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void ExportResultsHtml()
+ {
+ try
+ {
+ if (_lastResults == null || _lastResults.Count == 0)
+ {
+ MessageBox.Show(this, _translationService?.T("NoMatches") ?? "No matching conditions found based on the current selection.",
+ _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+ var t = _translationService;
+ string hCond = t?.T("ExportHeader_Condition") ?? "Condition";
+ string hScore = t?.T("ExportHeader_Score") ?? "Score";
+ string hMatches = t?.T("ExportHeader_Matches") ?? "Matches";
+ string hCategory = t?.T("ExportHeader_Category") ?? "Category";
+ string hMatched = t?.T("ExportHeader_MatchedSymptoms") ?? "Matched Symptoms";
+ string? initDir = _settingsService?.Settings.LastExportFolder;
+ var sfd = new SaveFileDialog { Filter = "HTML (*.html)|*.html|HTM (*.htm)|*.htm", FileName = "results.html", InitialDirectory = Directory.Exists(initDir) ? initDir : null };
+ if (sfd.ShowDialog(this) != DialogResult.OK) return;
+ RememberExportFolder(sfd.FileName);
+ var rows = GetExportTargetMatches();
+ var sb = new System.Text.StringBuilder();
+ sb.AppendLine("" + (t?.T("Title") ?? "Symptom Checker") + "");
+ sb.AppendLine("" + (t?.T("Title") ?? "Symptom Checker (Educational)") + "
");
+ if (_checkedSymptoms.Count > 0)
+ {
+ sb.AppendLine("" + (t?.T("SymptomsLabel") ?? "Symptoms:") + " " + string.Join(", ", _checkedSymptoms.Select(s => _translationService?.Symptom(s) ?? s)) + "
");
+ }
+ sb.AppendLine("| " + hCond + " | " + hScore + " | " + hMatches + " | " + hCategory + " | " + hMatched + " |
");
+ foreach (var m in rows)
+ {
+ string condDisp = _translationService?.Condition(m.Name) ?? m.Name;
+ string scoreStr = m.Score.ToString("F3", System.Globalization.CultureInfo.InvariantCulture);
+ string catDisp = DetermineBestCategoryDisplay(m.Name);
+ string matched = string.Join(", ", m.MatchedSymptoms.Select(s => _translationService?.Symptom(s) ?? s));
+ sb.AppendLine("| " + System.Net.WebUtility.HtmlEncode(condDisp) + " | " + scoreStr + " | " + m.MatchCount + " | " + System.Net.WebUtility.HtmlEncode(catDisp) + " | " + System.Net.WebUtility.HtmlEncode(matched) + " |
");
+ }
+ sb.AppendLine("
");
+ sb.AppendLine("Generated " + DateTime.Now.ToString("u") + " – " + (t?.T("Disclaimer") ?? "Educational only. Not medical advice.") + "
");
+ sb.AppendLine("");
+ File.WriteAllText(sfd.FileName, sb.ToString(), System.Text.Encoding.UTF8);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+}
diff --git a/UI/MainForm.ImageAnalysis.cs b/UI/MainForm.ImageAnalysis.cs
new file mode 100644
index 0000000..2108ccf
--- /dev/null
+++ b/UI/MainForm.ImageAnalysis.cs
@@ -0,0 +1,568 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Drawing.Drawing2D;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Forms;
+using SymptomCheckerApp.Models;
+using SymptomCheckerApp.Services;
+
+namespace SymptomCheckerApp.UI
+{
+ ///
+ /// Image analysis feature: upload a medical image and use a multimodal AI model
+ /// (e.g. LLaVA) to deduce symptoms and possible conditions.
+ ///
+ public partial class MainForm
+ {
+ // Image analysis UI controls
+ private readonly GroupBox _grpImageAnalysis = new GroupBox();
+ private readonly Button _imgUploadButton = new Button();
+ private readonly Button _imgAnalyzeButton = new Button();
+ private readonly Button _imgClearButton = new Button();
+ private readonly Button _imgApplySymptomsButton = new Button();
+ private readonly PictureBox _imgPreview = new PictureBox();
+ private readonly RichTextBox _imgResultBox = new RichTextBox();
+ private readonly ProgressBar _imgProgress = new ProgressBar();
+ private readonly Label _imgTimingLabel = new Label();
+ private readonly Label _imgInfoLabel = new Label();
+
+ private string? _currentImageBase64;
+ private ImageAnalysisResult? _lastImageAnalysisResult;
+ private CancellationTokenSource? _imgAnalysisCts;
+
+ /// Build and wire the Image Analysis panel. Called from InitializeLayout.
+ private void InitializeImageAnalysisPanel(Control parent)
+ {
+ _grpImageAnalysis.Text = "📷 Image Analysis (Vision AI)";
+ _grpImageAnalysis.Dock = DockStyle.Fill;
+ _grpImageAnalysis.Padding = new Padding(6);
+
+ var layout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 2,
+ RowCount = 4
+ };
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 30)); // image preview column
+ layout.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 70)); // results column
+ layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // toolbar
+ layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // progress
+ layout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // content
+ layout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // timing
+
+ // Toolbar row (spans both columns)
+ var toolbar = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Top,
+ AutoSize = true,
+ WrapContents = true,
+ FlowDirection = FlowDirection.LeftToRight,
+ Padding = new Padding(2)
+ };
+
+ _imgUploadButton.Text = "📁 Upload Image";
+ _imgUploadButton.AutoSize = true;
+ _imgUploadButton.FlatStyle = FlatStyle.Flat;
+ _imgUploadButton.BackColor = Color.FromArgb(230, 240, 255);
+ _imgUploadButton.AccessibleName = "Upload a medical image for analysis";
+ _imgUploadButton.Click += (s, e) => UploadImage();
+
+ _imgAnalyzeButton.Text = "🔍 Analyze Image";
+ _imgAnalyzeButton.AutoSize = true;
+ _imgAnalyzeButton.FlatStyle = FlatStyle.Flat;
+ _imgAnalyzeButton.BackColor = Color.FromArgb(255, 240, 230);
+ _imgAnalyzeButton.AccessibleName = "Analyze the uploaded image with AI";
+ _imgAnalyzeButton.Enabled = false;
+ _imgAnalyzeButton.Click += async (s, e) => await RunImageAnalysisAsync();
+
+ _imgApplySymptomsButton.Text = "✅ Apply Symptoms";
+ _imgApplySymptomsButton.AutoSize = true;
+ _imgApplySymptomsButton.FlatStyle = FlatStyle.Flat;
+ _imgApplySymptomsButton.BackColor = Color.FromArgb(230, 255, 230);
+ _imgApplySymptomsButton.AccessibleName = "Select deduced symptoms in the symptom list";
+ _imgApplySymptomsButton.Enabled = false;
+ _imgApplySymptomsButton.Click += (s, e) => ApplyDeducedSymptoms();
+
+ _imgClearButton.Text = "🗑 Clear";
+ _imgClearButton.AutoSize = true;
+ _imgClearButton.FlatStyle = FlatStyle.Flat;
+ _imgClearButton.AccessibleName = "Clear uploaded image";
+ _imgClearButton.Enabled = false;
+ _imgClearButton.Click += (s, e) => ClearImageAnalysis();
+
+ _imgInfoLabel.Text = "Supported: skin, throat, eye, mouth, nail — use a vision model (llava, bakllava, etc.)";
+ _imgInfoLabel.AutoSize = true;
+ _imgInfoLabel.ForeColor = Color.DimGray;
+ _imgInfoLabel.Padding = new Padding(6, 6, 0, 0);
+ _imgInfoLabel.Font = new Font(Font.FontFamily, 8f);
+
+ toolbar.Controls.Add(_imgUploadButton);
+ toolbar.Controls.Add(_imgAnalyzeButton);
+ toolbar.Controls.Add(_imgApplySymptomsButton);
+ toolbar.Controls.Add(_imgClearButton);
+ toolbar.Controls.Add(_imgInfoLabel);
+
+ layout.Controls.Add(toolbar, 0, 0);
+ layout.SetColumnSpan(toolbar, 2);
+
+ // Progress bar (spans both columns)
+ _imgProgress.Dock = DockStyle.Top;
+ _imgProgress.Style = ProgressBarStyle.Marquee;
+ _imgProgress.MarqueeAnimationSpeed = 30;
+ _imgProgress.Height = 4;
+ _imgProgress.Visible = false;
+ layout.Controls.Add(_imgProgress, 0, 1);
+ layout.SetColumnSpan(_imgProgress, 2);
+
+ // Image preview (left column)
+ _imgPreview.Dock = DockStyle.Fill;
+ _imgPreview.SizeMode = PictureBoxSizeMode.Zoom;
+ _imgPreview.BorderStyle = BorderStyle.FixedSingle;
+ _imgPreview.BackColor = Color.FromArgb(245, 245, 245);
+ _imgPreview.AccessibleName = "Image preview";
+ // Placeholder text via Paint event
+ _imgPreview.Paint += (s, e) =>
+ {
+ if (_imgPreview.Image == null)
+ {
+ var text = "Drop or upload\nan image here";
+ var font = new Font("Segoe UI", 10f, FontStyle.Italic);
+ var brush = new SolidBrush(Color.FromArgb(150, 150, 150));
+ var sf = new StringFormat { Alignment = StringAlignment.Center, LineAlignment = StringAlignment.Center };
+ e.Graphics.DrawString(text, font, brush, _imgPreview.ClientRectangle, sf);
+ font.Dispose();
+ brush.Dispose();
+ }
+ };
+ // Enable drag and drop
+ _imgPreview.AllowDrop = true;
+ _imgPreview.DragEnter += (s, e) =>
+ {
+ if (e.Data != null && e.Data.GetDataPresent(DataFormats.FileDrop))
+ e.Effect = DragDropEffects.Copy;
+ };
+ _imgPreview.DragDrop += (s, e) =>
+ {
+ if (e.Data?.GetData(DataFormats.FileDrop) is string[] files && files.Length > 0)
+ LoadImageFile(files[0]);
+ };
+ layout.Controls.Add(_imgPreview, 0, 2);
+
+ // Results box (right column)
+ _imgResultBox.Dock = DockStyle.Fill;
+ _imgResultBox.ReadOnly = true;
+ _imgResultBox.BorderStyle = BorderStyle.FixedSingle;
+ _imgResultBox.BackColor = SystemColors.Window;
+ _imgResultBox.Font = new Font("Segoe UI", 9.5f);
+ _imgResultBox.AccessibleName = "Image analysis results";
+ _imgResultBox.Text = "Upload a medical image (photo of skin condition, throat, eye, etc.) and click 'Analyze Image'.\n\n" +
+ "The AI vision model will:\n" +
+ " • Identify the body region\n" +
+ " • Describe visual observations\n" +
+ " • Deduce possible symptoms\n" +
+ " • Suggest possible conditions\n\n" +
+ "You can then apply the deduced symptoms to the symptom checker.\n\n" +
+ "⚠ Requires a multimodal model in Ollama (e.g. llava, bakllava, llava-llama3).";
+ layout.Controls.Add(_imgResultBox, 1, 2);
+
+ // Timing label (spans both columns)
+ _imgTimingLabel.Text = "";
+ _imgTimingLabel.AutoSize = true;
+ _imgTimingLabel.Padding = new Padding(4);
+ _imgTimingLabel.ForeColor = Color.DimGray;
+ _imgTimingLabel.AccessibleName = "Image analysis timing";
+ layout.Controls.Add(_imgTimingLabel, 0, 3);
+ layout.SetColumnSpan(_imgTimingLabel, 2);
+
+ _grpImageAnalysis.Controls.Add(layout);
+ parent.Controls.Add(_grpImageAnalysis);
+ }
+
+ /// Open a file dialog to pick an image.
+ private void UploadImage()
+ {
+ using var dlg = new OpenFileDialog
+ {
+ Title = "Select a medical image",
+ Filter = "Image files (*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp)|*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.webp|All files (*.*)|*.*",
+ RestoreDirectory = true
+ };
+
+ if (dlg.ShowDialog(this) == DialogResult.OK)
+ {
+ LoadImageFile(dlg.FileName);
+ }
+ }
+
+ /// Load an image file, display preview, and prepare base64.
+ private void LoadImageFile(string filePath)
+ {
+ try
+ {
+ if (!File.Exists(filePath)) return;
+
+ var ext = Path.GetExtension(filePath).ToLowerInvariant();
+ var validExts = new HashSet { ".jpg", ".jpeg", ".png", ".bmp", ".gif", ".webp" };
+ if (!validExts.Contains(ext))
+ {
+ MessageBox.Show(this, "Unsupported image format. Please use JPG, PNG, BMP, GIF or WebP.",
+ "Invalid Format", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ // Check file size (limit to 20MB)
+ var fileInfo = new FileInfo(filePath);
+ if (fileInfo.Length > 20 * 1024 * 1024)
+ {
+ MessageBox.Show(this, "Image file is too large. Maximum size is 20 MB.",
+ "File Too Large", MessageBoxButtons.OK, MessageBoxIcon.Warning);
+ return;
+ }
+
+ // Load image for preview
+ var imageBytes = File.ReadAllBytes(filePath);
+ _currentImageBase64 = Convert.ToBase64String(imageBytes);
+
+ using var ms = new MemoryStream(imageBytes);
+ var oldImage = _imgPreview.Image;
+ _imgPreview.Image = Image.FromStream(ms);
+ oldImage?.Dispose();
+
+ _imgAnalyzeButton.Enabled = true;
+ _imgClearButton.Enabled = true;
+ _imgApplySymptomsButton.Enabled = false;
+ _lastImageAnalysisResult = null;
+
+ _imgResultBox.Clear();
+ _imgResultBox.Text = $"Image loaded: {Path.GetFileName(filePath)}\n" +
+ $"Size: {fileInfo.Length / 1024} KB\n\n" +
+ "Click 'Analyze Image' to start AI analysis.\n" +
+ "Make sure a vision model (llava, bakllava) is selected in the AI panel above.";
+ _imgTimingLabel.Text = "";
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, $"Error loading image: {ex.Message}", "Error",
+ MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ /// Clear the uploaded image and results.
+ private void ClearImageAnalysis()
+ {
+ var oldImage = _imgPreview.Image;
+ _imgPreview.Image = null;
+ oldImage?.Dispose();
+
+ _currentImageBase64 = null;
+ _lastImageAnalysisResult = null;
+ _imgAnalyzeButton.Enabled = false;
+ _imgClearButton.Enabled = false;
+ _imgApplySymptomsButton.Enabled = false;
+ _imgTimingLabel.Text = "";
+
+ _imgResultBox.Clear();
+ _imgResultBox.Text = "Upload a medical image to analyze.";
+ _imgPreview.Invalidate(); // force repaint for placeholder text
+ }
+
+ /// Run the AI image analysis using the Ollama vision model.
+ private async Task RunImageAnalysisAsync()
+ {
+ if (_ollamaService == null || string.IsNullOrEmpty(_currentImageBase64))
+ {
+ _imgResultBox.Text = "Please upload an image and ensure Ollama is connected.";
+ return;
+ }
+
+ // Ensure correct model is set from the main Ollama panel selector
+ var selectedModel = _ollamaModelSelector.SelectedItem?.ToString();
+ if (!string.IsNullOrEmpty(selectedModel))
+ {
+ _ollamaService.SetModel(selectedModel);
+ }
+ else
+ {
+ _imgResultBox.Text = "Please select a vision model (e.g. llava) in the AI panel above.";
+ return;
+ }
+
+ _imgAnalysisCts?.Cancel();
+ _imgAnalysisCts = new CancellationTokenSource();
+
+ SetImageAnalysisLoading(true);
+
+ try
+ {
+ var lang = _translationService?.CurrentLanguage ?? "en";
+ var result = await _ollamaService.AnalyzeImageAsync(
+ _currentImageBase64, lang, _imgAnalysisCts.Token);
+
+ _lastImageAnalysisResult = result;
+ DisplayImageAnalysisResult(result);
+
+ _imgApplySymptomsButton.Enabled = result.DeducedSymptoms.Count > 0;
+ }
+ catch (OperationCanceledException)
+ {
+ _imgResultBox.Text = "Image analysis cancelled.";
+ }
+ catch (Exception ex)
+ {
+ _imgResultBox.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ SetImageAnalysisLoading(false);
+ }
+ }
+
+ private void SetImageAnalysisLoading(bool loading)
+ {
+ _imgProgress.Visible = loading;
+ _imgAnalyzeButton.Enabled = !loading && !string.IsNullOrEmpty(_currentImageBase64);
+ _imgUploadButton.Enabled = !loading;
+ _imgClearButton.Enabled = !loading && _imgPreview.Image != null;
+ if (loading)
+ {
+ _imgResultBox.Clear();
+ _imgResultBox.Text = "🔍 AI is analyzing the image... please wait...\n\n" +
+ "(This may take 30-90 seconds depending on the model and hardware)";
+ _imgTimingLabel.Text = "";
+ }
+ }
+
+ /// Render the image analysis result with formatting.
+ private void DisplayImageAnalysisResult(ImageAnalysisResult result)
+ {
+ _imgResultBox.Clear();
+ var rtb = _imgResultBox;
+
+ void AppendHeader(string text)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 11f, FontStyle.Bold);
+ rtb.SelectionColor = Color.FromArgb(30, 80, 160);
+ rtb.AppendText(text + "\n");
+ }
+
+ void AppendBody(string text)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular);
+ rtb.SelectionColor = rtb.ForeColor;
+ rtb.AppendText(text + "\n");
+ }
+
+ void AppendBullet(string text, Color color)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular);
+ rtb.SelectionColor = color;
+ rtb.AppendText($" • {text}\n");
+ }
+
+ // Body Region
+ if (!string.IsNullOrWhiteSpace(result.BodyRegion))
+ {
+ AppendHeader("🔬 Body Region");
+ AppendBody($" {result.BodyRegion}");
+ rtb.AppendText("\n");
+ }
+
+ // Observations
+ if (result.Observations.Count > 0)
+ {
+ AppendHeader("👁 Visual Observations");
+ foreach (var obs in result.Observations)
+ AppendBullet(obs, Color.FromArgb(60, 60, 60));
+ rtb.AppendText("\n");
+ }
+
+ // Deduced Symptoms
+ if (result.DeducedSymptoms.Count > 0)
+ {
+ AppendHeader("🩺 Deduced Symptoms");
+ foreach (var symptom in result.DeducedSymptoms)
+ {
+ // Check if symptom matches one in our database
+ bool isKnown = _allSymptoms.Any(s =>
+ s.Contains(symptom, StringComparison.OrdinalIgnoreCase) ||
+ symptom.Contains(s, StringComparison.OrdinalIgnoreCase));
+ var color = isKnown ? Color.FromArgb(0, 120, 60) : Color.FromArgb(180, 100, 0);
+ var suffix = isKnown ? " ✓ (in database)" : " (not in database)";
+ AppendBullet(symptom + suffix, color);
+ }
+ rtb.AppendText("\n");
+
+ // Show apply button hint
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic);
+ rtb.SelectionColor = Color.FromArgb(0, 100, 150);
+ rtb.AppendText(" 💡 Click 'Apply Symptoms' to auto-select matching symptoms in the checker.\n\n");
+ }
+
+ // Possible Conditions
+ if (result.PossibleConditions.Count > 0)
+ {
+ AppendHeader("🏥 Possible Conditions");
+ foreach (var cond in result.PossibleConditions)
+ AppendBullet(cond, Color.FromArgb(160, 60, 0));
+ rtb.AppendText("\n");
+ }
+
+ // Severity
+ if (!string.IsNullOrWhiteSpace(result.Severity))
+ {
+ AppendHeader("⚡ Severity");
+ var sevColor = result.Severity.ToLowerInvariant() switch
+ {
+ var s when s.Contains("severe") => Color.FromArgb(200, 0, 0),
+ var s when s.Contains("moderate") => Color.FromArgb(200, 140, 0),
+ _ => Color.FromArgb(0, 128, 0)
+ };
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 10f, FontStyle.Bold);
+ rtb.SelectionColor = sevColor;
+ rtb.AppendText($" {result.Severity}\n\n");
+ }
+
+ // Recommendation
+ if (!string.IsNullOrWhiteSpace(result.Recommendation))
+ {
+ AppendHeader("💡 Recommendation");
+ AppendBody($" {result.Recommendation}");
+ rtb.AppendText("\n");
+ }
+
+ // Disclaimer
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic);
+ rtb.SelectionColor = Color.Gray;
+ string disclaimer = !string.IsNullOrWhiteSpace(result.Disclaimer)
+ ? result.Disclaimer
+ : "⚠️ This image analysis is for educational purposes only. Always consult a healthcare professional for proper diagnosis.";
+ rtb.AppendText($"\n{disclaimer}\n");
+
+ // Timing
+ _imgTimingLabel.Text = $"Image analysis: {result.ElapsedMs} ms | Model: {_ollamaService?.ModelName ?? "?"}";
+
+ rtb.SelectionStart = 0;
+ rtb.ScrollToCaret();
+ }
+
+ ///
+ /// Apply the deduced symptoms from image analysis to the symptom checker.
+ /// Uses fuzzy matching against known symptoms in the database.
+ ///
+ private void ApplyDeducedSymptoms()
+ {
+ if (_lastImageAnalysisResult == null || _lastImageAnalysisResult.DeducedSymptoms.Count == 0)
+ {
+ MessageBox.Show(this, "No symptoms deduced from image analysis yet.",
+ "No Symptoms", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ return;
+ }
+
+ int matched = 0;
+ var matchedNames = new List();
+ var unmatchedNames = new List();
+
+ foreach (var deducedSymptom in _lastImageAnalysisResult.DeducedSymptoms)
+ {
+ // Try exact match first, then partial/fuzzy match
+ string? bestMatch = null;
+ int bestScore = 0;
+
+ foreach (var knownSymptom in _allSymptoms)
+ {
+ // Exact match
+ if (string.Equals(knownSymptom, deducedSymptom, StringComparison.OrdinalIgnoreCase))
+ {
+ bestMatch = knownSymptom;
+ bestScore = 100;
+ break;
+ }
+
+ // Check if known symptom contains the deduced symptom or vice versa
+ if (knownSymptom.Contains(deducedSymptom, StringComparison.OrdinalIgnoreCase) ||
+ deducedSymptom.Contains(knownSymptom, StringComparison.OrdinalIgnoreCase))
+ {
+ int score = 80;
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestMatch = knownSymptom;
+ }
+ }
+
+ // Word-level match: check if any significant word from deduced symptom appears
+ var deducedWords = deducedSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var knownWords = knownSymptom.ToLowerInvariant().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var commonWords = deducedWords.Intersect(knownWords).Where(w => w.Length > 3).ToList();
+ if (commonWords.Count > 0)
+ {
+ int score = 40 + (commonWords.Count * 15);
+ if (score > bestScore)
+ {
+ bestScore = score;
+ bestMatch = knownSymptom;
+ }
+ }
+ }
+
+ // Also check synonyms if available
+ if (bestScore < 60 && _synonymService != null)
+ {
+ var aliasMap = _synonymService.BuildAliasToCanonical(_allSymptoms);
+ if (aliasMap.TryGetValue(deducedSymptom, out var canonical) &&
+ _allSymptoms.Contains(canonical, StringComparer.OrdinalIgnoreCase))
+ {
+ bestMatch = canonical;
+ bestScore = 90;
+ }
+ }
+
+ if (bestMatch != null && bestScore >= 40)
+ {
+ // Select this symptom in the CheckedListBox
+ if (_checkedSymptoms.Add(bestMatch))
+ {
+ matched++;
+ matchedNames.Add($"{deducedSymptom} → {bestMatch}");
+ }
+ }
+ else
+ {
+ unmatchedNames.Add(deducedSymptom);
+ }
+ }
+
+ // Refresh the symptom list display to reflect new selections
+ RefreshSymptomList();
+
+ // Update check button state
+ _checkButton.Enabled = _checkedSymptoms.Count > 0;
+
+ // Show feedback
+ var msg = $"Applied {matched} symptom(s) from image analysis.\n\n";
+ if (matchedNames.Count > 0)
+ msg += "Matched:\n" + string.Join("\n", matchedNames.Select(m => $" ✓ {m}")) + "\n\n";
+ if (unmatchedNames.Count > 0)
+ msg += "Not matched (not in database):\n" + string.Join("\n", unmatchedNames.Select(u => $" ✗ {u}"));
+
+ MessageBox.Show(this, msg, "Symptoms Applied", MessageBoxButtons.OK, MessageBoxIcon.Information);
+ }
+
+ /// Apply translations to image analysis panel labels.
+ private void ApplyImageAnalysisTranslations()
+ {
+ var t = _translationService;
+ if (t == null) return;
+
+ _grpImageAnalysis.Text = t.T("ImageAnalysisTitle") ?? "📷 Image Analysis (Vision AI)";
+ _imgUploadButton.Text = t.T("ImageUpload") ?? "📁 Upload Image";
+ _imgAnalyzeButton.Text = t.T("ImageAnalyze") ?? "🔍 Analyze Image";
+ _imgApplySymptomsButton.Text = t.T("ImageApplySymptoms") ?? "✅ Apply Symptoms";
+ _imgClearButton.Text = t.T("ImageClear") ?? "🗑 Clear";
+ }
+ }
+}
diff --git a/UI/MainForm.Ollama.cs b/UI/MainForm.Ollama.cs
new file mode 100644
index 0000000..6360aff
--- /dev/null
+++ b/UI/MainForm.Ollama.cs
@@ -0,0 +1,550 @@
+using System;
+using System.Drawing;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Forms;
+using SymptomCheckerApp.Models;
+using SymptomCheckerApp.Services;
+
+namespace SymptomCheckerApp.UI
+{
+ // AI-powered diagnosis reinforcement and medication proposals via Ollama
+ public partial class MainForm
+ {
+ private OllamaService? _ollamaService;
+ private CancellationTokenSource? _ollamaCts;
+ private AiDiagnosisResult? _lastAiResult;
+
+ // Ollama UI controls
+ private readonly GroupBox _grpAi = new GroupBox();
+ private readonly Button _aiDiagnoseButton = new Button();
+ private readonly Button _aiMedsButton = new Button();
+ private readonly ComboBox _ollamaModelSelector = new ComboBox();
+ private readonly Button _ollamaRefreshButton = new Button();
+ private readonly Label _ollamaStatus = new Label();
+ private readonly TextBox _aiUrlBox = new TextBox();
+ private readonly RichTextBox _aiOutputBox = new RichTextBox();
+ private readonly ProgressBar _aiProgress = new ProgressBar();
+ private readonly Label _aiTimingLabel = new Label();
+ private readonly CheckBox _autoAiCheck = new CheckBox();
+
+ /// Build and wire the AI / Ollama panel controls. Called from InitializeLayout.
+ private void InitializeOllamaPanel(Control parent)
+ {
+ _grpAi.Text = "🤖 AI Diagnostic Assistant (Ollama)";
+ _grpAi.Dock = DockStyle.Fill;
+ _grpAi.Padding = new Padding(6);
+
+ var aiLayout = new TableLayoutPanel
+ {
+ Dock = DockStyle.Fill,
+ ColumnCount = 1,
+ RowCount = 4
+ };
+ aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // toolbar
+ aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // progress
+ aiLayout.RowStyles.Add(new RowStyle(SizeType.Percent, 100)); // output
+ aiLayout.RowStyles.Add(new RowStyle(SizeType.AutoSize)); // timing
+
+ // Toolbar row
+ var aiToolbar = new FlowLayoutPanel
+ {
+ Dock = DockStyle.Top,
+ AutoSize = true,
+ WrapContents = true,
+ FlowDirection = FlowDirection.LeftToRight,
+ Padding = new Padding(2)
+ };
+
+ var lblUrl = new Label { Text = "URL:", AutoSize = true, Padding = new Padding(0, 6, 0, 0) };
+ _aiUrlBox.Text = "http://localhost:11434";
+ _aiUrlBox.Width = ScaleX(160);
+ _aiUrlBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
+ _aiUrlBox.AccessibleName = "Ollama server URL";
+
+ var lblModel = new Label { Text = "Model:", AutoSize = true, Padding = new Padding(6, 6, 0, 0) };
+ _ollamaModelSelector.DropDownStyle = ComboBoxStyle.DropDownList;
+ _ollamaModelSelector.Width = ScaleX(140);
+ _ollamaModelSelector.AccessibleName = "Ollama model selector";
+
+ _ollamaRefreshButton.Text = "⟳";
+ _ollamaRefreshButton.Width = ScaleX(32);
+ _ollamaRefreshButton.FlatStyle = FlatStyle.Flat;
+ _ollamaRefreshButton.AccessibleName = "Refresh Ollama models";
+ _ollamaRefreshButton.Click += async (s, e) => await RefreshOllamaModelsAsync();
+
+ _ollamaStatus.Text = "⬤ Disconnected";
+ _ollamaStatus.ForeColor = Color.Gray;
+ _ollamaStatus.AutoSize = true;
+ _ollamaStatus.Padding = new Padding(6, 6, 0, 0);
+ _ollamaStatus.AccessibleName = "Ollama connection status";
+
+ _aiDiagnoseButton.Text = "🧠 AI Diagnosis";
+ _aiDiagnoseButton.AutoSize = true;
+ _aiDiagnoseButton.FlatStyle = FlatStyle.Flat;
+ _aiDiagnoseButton.BackColor = Color.FromArgb(230, 240, 255);
+ _aiDiagnoseButton.AccessibleName = "Run AI-powered diagnostic analysis";
+ _aiDiagnoseButton.Click += async (s, e) => await RunAiDiagnosisAsync();
+
+ _aiMedsButton.Text = "💊 AI Medications";
+ _aiMedsButton.AutoSize = true;
+ _aiMedsButton.FlatStyle = FlatStyle.Flat;
+ _aiMedsButton.BackColor = Color.FromArgb(230, 255, 230);
+ _aiMedsButton.AccessibleName = "Get AI medication recommendations";
+ _aiMedsButton.Click += async (s, e) => await RunAiMedicationAdviceAsync();
+
+ _autoAiCheck.Text = "Auto AI";
+ _autoAiCheck.AutoSize = true;
+ _autoAiCheck.AccessibleName = "Automatically run AI after check";
+ _autoAiCheck.CheckedChanged += (s, e) =>
+ {
+ if (_settingsService != null) { _settingsService.Settings.AutoAi = _autoAiCheck.Checked; _settingsService.Save(); }
+ };
+
+ _aiUrlBox.Leave += (s, e) =>
+ {
+ if (_settingsService != null) { _settingsService.Settings.OllamaUrl = _aiUrlBox.Text?.Trim(); _settingsService.Save(); }
+ };
+
+ _ollamaModelSelector.SelectedIndexChanged += (s, e) =>
+ {
+ if (_settingsService != null && _ollamaModelSelector.SelectedItem != null)
+ {
+ _settingsService.Settings.OllamaModel = _ollamaModelSelector.SelectedItem.ToString();
+ _settingsService.Save();
+ }
+ };
+
+ aiToolbar.Controls.Add(lblUrl);
+ aiToolbar.Controls.Add(_aiUrlBox);
+ aiToolbar.Controls.Add(lblModel);
+ aiToolbar.Controls.Add(_ollamaModelSelector);
+ aiToolbar.Controls.Add(_ollamaRefreshButton);
+ aiToolbar.Controls.Add(_ollamaStatus);
+ aiToolbar.Controls.Add(_aiDiagnoseButton);
+ aiToolbar.Controls.Add(_aiMedsButton);
+ aiToolbar.Controls.Add(_autoAiCheck);
+
+ // Progress bar
+ _aiProgress.Dock = DockStyle.Top;
+ _aiProgress.Style = ProgressBarStyle.Marquee;
+ _aiProgress.MarqueeAnimationSpeed = 30;
+ _aiProgress.Height = 4;
+ _aiProgress.Visible = false;
+
+ // Output box
+ _aiOutputBox.Dock = DockStyle.Fill;
+ _aiOutputBox.ReadOnly = true;
+ _aiOutputBox.BorderStyle = BorderStyle.FixedSingle;
+ _aiOutputBox.BackColor = SystemColors.Window;
+ _aiOutputBox.Font = new Font("Segoe UI", 9.5f);
+ _aiOutputBox.AccessibleName = "AI diagnostic output";
+ _aiOutputBox.Text = "AI analysis will appear here after you run a symptom check and click 'AI Diagnosis'.\n\nRequires Ollama running locally (https://ollama.com).";
+
+ // Timing
+ _aiTimingLabel.Text = "";
+ _aiTimingLabel.AutoSize = true;
+ _aiTimingLabel.Padding = new Padding(4);
+ _aiTimingLabel.ForeColor = Color.DimGray;
+ _aiTimingLabel.AccessibleName = "AI response timing";
+
+ aiLayout.Controls.Add(aiToolbar, 0, 0);
+ aiLayout.Controls.Add(_aiProgress, 0, 1);
+ aiLayout.Controls.Add(_aiOutputBox, 0, 2);
+ aiLayout.Controls.Add(_aiTimingLabel, 0, 3);
+
+ _grpAi.Controls.Add(aiLayout);
+ parent.Controls.Add(_grpAi);
+ }
+
+ /// Initialize or re-initialize the OllamaService and check availability.
+ private async Task InitializeOllamaAsync()
+ {
+ var url = _aiUrlBox.Text?.Trim();
+ if (string.IsNullOrEmpty(url)) url = "http://localhost:11434";
+
+ _ollamaService?.Dispose();
+ _ollamaService = new OllamaService(url);
+
+ var available = await _ollamaService.CheckAvailabilityAsync();
+ UpdateOllamaStatusUI(available);
+
+ if (available)
+ {
+ await RefreshOllamaModelsAsync();
+ }
+ }
+
+ private async Task RefreshOllamaModelsAsync()
+ {
+ try
+ {
+ var url = _aiUrlBox.Text?.Trim();
+ if (string.IsNullOrEmpty(url)) url = "http://localhost:11434";
+
+ if (_ollamaService == null || _ollamaService.BaseUrl != url.TrimEnd('/'))
+ {
+ _ollamaService?.Dispose();
+ _ollamaService = new OllamaService(url);
+ }
+
+ var available = await _ollamaService.CheckAvailabilityAsync();
+ UpdateOllamaStatusUI(available);
+
+ if (!available) return;
+
+ var models = await _ollamaService.ListModelsAsync();
+ _ollamaModelSelector.Items.Clear();
+ foreach (var m in models)
+ _ollamaModelSelector.Items.Add(m);
+
+ if (_ollamaModelSelector.Items.Count > 0)
+ {
+ // Prefer models with "llama" or "mistral" in the name
+ int bestIdx = 0;
+ for (int i = 0; i < _ollamaModelSelector.Items.Count; i++)
+ {
+ var name = _ollamaModelSelector.Items[i]?.ToString() ?? "";
+ if (name.Contains("llama", StringComparison.OrdinalIgnoreCase) ||
+ name.Contains("mistral", StringComparison.OrdinalIgnoreCase))
+ {
+ bestIdx = i;
+ break;
+ }
+ }
+
+ // Restore from settings if available
+ if (_settingsService?.Settings.OllamaModel is string savedModel && !string.IsNullOrEmpty(savedModel))
+ {
+ for (int i = 0; i < _ollamaModelSelector.Items.Count; i++)
+ {
+ if (string.Equals(_ollamaModelSelector.Items[i]?.ToString(), savedModel, StringComparison.OrdinalIgnoreCase))
+ {
+ bestIdx = i;
+ break;
+ }
+ }
+ }
+
+ _ollamaModelSelector.SelectedIndex = bestIdx;
+ }
+ }
+ catch (Exception ex)
+ {
+ UpdateOllamaStatusUI(false);
+ _aiOutputBox.Text = $"Error listing models: {ex.Message}";
+ }
+ }
+
+ private void UpdateOllamaStatusUI(bool available)
+ {
+ if (available)
+ {
+ _ollamaStatus.Text = "⬤ Connected";
+ _ollamaStatus.ForeColor = Color.FromArgb(0, 128, 0);
+ _aiDiagnoseButton.Enabled = true;
+ _aiMedsButton.Enabled = true;
+ }
+ else
+ {
+ _ollamaStatus.Text = "⬤ Disconnected";
+ _ollamaStatus.ForeColor = Color.FromArgb(180, 0, 0);
+ _aiDiagnoseButton.Enabled = false;
+ _aiMedsButton.Enabled = false;
+ }
+ }
+
+ /// Run full AI diagnostic assessment with medication suggestions.
+ private async Task RunAiDiagnosisAsync()
+ {
+ if (_ollamaService == null || _lastResults == null || _lastResults.Count == 0)
+ {
+ _aiOutputBox.Text = _translationService?.T("AiNoResults") ??
+ "Please run a symptom check first (select symptoms and click Check).";
+ return;
+ }
+
+ // Ensure correct model is set
+ var selectedModel = _ollamaModelSelector.SelectedItem?.ToString();
+ if (!string.IsNullOrEmpty(selectedModel))
+ {
+ _ollamaService.SetModel(selectedModel);
+ if (_settingsService != null)
+ {
+ _settingsService.Settings.OllamaModel = selectedModel;
+ _settingsService.Save();
+ }
+ }
+
+ _ollamaCts?.Cancel();
+ _ollamaCts = new CancellationTokenSource();
+
+ SetAiLoading(true);
+
+ try
+ {
+ var lang = _translationService?.CurrentLanguage ?? "en";
+ double? age = _numAge.Value > 0 ? (double)_numAge.Value : null;
+ double? tempC = _numTempC.Value > 30 ? (double)_numTempC.Value : null;
+ int? hr = (int)_numHR.Value > 20 ? (int)_numHR.Value : null;
+ int? rr = (int)_numRR.Value > 4 ? (int)_numRR.Value : null;
+ int? spo2 = (int)_numSpO2.Value > 50 ? (int)_numSpO2.Value : null;
+
+ var result = await _ollamaService.GetDiagnosisReinforcementAsync(
+ _checkedSymptoms.ToList(),
+ _lastResults,
+ lang, age, tempC, hr, rr, spo2,
+ _ollamaCts.Token);
+
+ _lastAiResult = result;
+ DisplayAiResult(result);
+ }
+ catch (OperationCanceledException)
+ {
+ _aiOutputBox.Text = "AI analysis cancelled.";
+ }
+ catch (Exception ex)
+ {
+ _aiOutputBox.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ SetAiLoading(false);
+ }
+ }
+
+ /// Run AI medication recommendations for the selected condition.
+ private async Task RunAiMedicationAdviceAsync()
+ {
+ if (_ollamaService == null) return;
+
+ // Get selected condition from results list
+ string? conditionName = null;
+ var matchedSymptoms = new System.Collections.Generic.List();
+
+ int idx = _resultsList.SelectedIndex;
+ if (idx >= 0 && idx < _resultIndexMap.Count)
+ {
+ int ri = _resultIndexMap[idx];
+ if (ri >= 0 && ri < _lastResults.Count)
+ {
+ conditionName = _lastResults[ri].Name;
+ matchedSymptoms = _lastResults[ri].MatchedSymptoms;
+ }
+ }
+
+ if (string.IsNullOrEmpty(conditionName) && _lastResults?.Count > 0)
+ {
+ conditionName = _lastResults[0].Name;
+ matchedSymptoms = _lastResults[0].MatchedSymptoms;
+ }
+
+ if (string.IsNullOrEmpty(conditionName))
+ {
+ _aiOutputBox.Text = _translationService?.T("AiSelectCondition") ??
+ "Please run a symptom check and select a condition for medication advice.";
+ return;
+ }
+
+ var selectedModel = _ollamaModelSelector.SelectedItem?.ToString();
+ if (!string.IsNullOrEmpty(selectedModel))
+ _ollamaService.SetModel(selectedModel);
+
+ _ollamaCts?.Cancel();
+ _ollamaCts = new CancellationTokenSource();
+
+ SetAiLoading(true);
+
+ try
+ {
+ var lang = _translationService?.CurrentLanguage ?? "en";
+ double? age = _numAge.Value > 0 ? (double)_numAge.Value : null;
+
+ var result = await _ollamaService.GetMedicationAdviceAsync(
+ conditionName, matchedSymptoms, lang, age, _ollamaCts.Token);
+
+ _lastAiResult = result;
+ DisplayAiResult(result);
+ }
+ catch (OperationCanceledException)
+ {
+ _aiOutputBox.Text = "Cancelled.";
+ }
+ catch (Exception ex)
+ {
+ _aiOutputBox.Text = $"Error: {ex.Message}";
+ }
+ finally
+ {
+ SetAiLoading(false);
+ }
+ }
+
+ private void SetAiLoading(bool loading)
+ {
+ _aiProgress.Visible = loading;
+ _aiDiagnoseButton.Enabled = !loading && (_ollamaService?.IsAvailable ?? false);
+ _aiMedsButton.Enabled = !loading && (_ollamaService?.IsAvailable ?? false);
+ if (loading)
+ {
+ _aiOutputBox.Text = _translationService?.T("AiThinking") ?? "🤖 AI is analyzing... please wait...";
+ _aiTimingLabel.Text = "";
+ }
+ }
+
+ /// Render the AI result with color formatting in the RichTextBox.
+ private void DisplayAiResult(AiDiagnosisResult result)
+ {
+ _aiOutputBox.Clear();
+ var rtb = _aiOutputBox;
+
+ void AppendHeader(string text)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 11f, FontStyle.Bold);
+ rtb.SelectionColor = Color.FromArgb(30, 80, 160);
+ rtb.AppendText(text + "\n");
+ }
+
+ void AppendBody(string text)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Regular);
+ rtb.SelectionColor = rtb.ForeColor;
+ rtb.AppendText(text + "\n");
+ }
+
+ void AppendMedication(MedicationProposal med)
+ {
+ // Name in bold
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Bold);
+ bool isOtc = med.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase);
+ rtb.SelectionColor = isOtc ? Color.FromArgb(0, 120, 60) : Color.FromArgb(160, 80, 0);
+ rtb.AppendText($" 💊 {med.Name}");
+
+ // Category badge
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8f, FontStyle.Italic);
+ rtb.SelectionColor = isOtc ? Color.FromArgb(0, 100, 50) : Color.FromArgb(180, 60, 0);
+ rtb.AppendText($" [{med.Category}]\n");
+
+ // Details
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9f, FontStyle.Regular);
+ rtb.SelectionColor = rtb.ForeColor;
+ if (!string.IsNullOrEmpty(med.Purpose))
+ rtb.AppendText($" Purpose: {med.Purpose}\n");
+ if (!string.IsNullOrEmpty(med.Dosage))
+ rtb.AppendText($" Dosage: {med.Dosage}\n");
+
+ if (!string.IsNullOrEmpty(med.Warning))
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9f, FontStyle.Italic);
+ rtb.SelectionColor = Color.FromArgb(180, 0, 0);
+ rtb.AppendText($" ⚠ {med.Warning}\n");
+ }
+
+ rtb.AppendText("\n");
+ }
+
+ void AppendRedFlag(string flag)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Bold);
+ rtb.SelectionColor = Color.FromArgb(200, 0, 0);
+ rtb.AppendText($" 🚩 {flag}\n");
+ }
+
+ // Diagnostic Assessment
+ if (!string.IsNullOrWhiteSpace(result.DiagnosticAssessment))
+ {
+ AppendHeader("🧠 Diagnostic Assessment");
+ AppendBody(result.DiagnosticAssessment);
+ rtb.AppendText("\n");
+ }
+
+ // Confidence reinforcement
+ if (result.ConfidenceReinforcement.HasValue)
+ {
+ AppendHeader("📊 AI Confidence");
+ double conf = result.ConfidenceReinforcement.Value;
+ string confBar = new string('█', (int)(conf * 20)) + new string('░', 20 - (int)(conf * 20));
+ rtb.SelectionFont = new Font("Consolas", 10f, FontStyle.Regular);
+ rtb.SelectionColor = conf > 0.7 ? Color.FromArgb(0, 128, 0) :
+ conf > 0.4 ? Color.FromArgb(180, 140, 0) :
+ Color.FromArgb(180, 0, 0);
+ rtb.AppendText($" [{confBar}] {conf:P0}\n\n");
+ }
+
+ // Medications
+ if (result.Medications.Count > 0)
+ {
+ AppendHeader("💊 Medication Proposals");
+ // Separate OTC and prescription
+ var otcMeds = result.Medications.Where(m =>
+ m.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase) ||
+ m.Category.Contains("over", StringComparison.OrdinalIgnoreCase)).ToList();
+ var rxMeds = result.Medications.Where(m =>
+ !m.Category.Contains("OTC", StringComparison.OrdinalIgnoreCase) &&
+ !m.Category.Contains("over", StringComparison.OrdinalIgnoreCase)).ToList();
+
+ if (otcMeds.Count > 0)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Italic);
+ rtb.SelectionColor = Color.FromArgb(0, 100, 50);
+ rtb.AppendText(" Over-the-Counter:\n");
+ foreach (var med in otcMeds) AppendMedication(med);
+ }
+
+ if (rxMeds.Count > 0)
+ {
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 9.5f, FontStyle.Italic);
+ rtb.SelectionColor = Color.FromArgb(180, 80, 0);
+ rtb.AppendText(" Prescription (requires doctor):\n");
+ foreach (var med in rxMeds) AppendMedication(med);
+ }
+
+ rtb.AppendText("\n");
+ }
+
+ // Red Flags
+ if (result.RedFlags.Count > 0)
+ {
+ AppendHeader("🚩 Red Flags — Seek Immediate Care");
+ foreach (var flag in result.RedFlags) AppendRedFlag(flag);
+ rtb.AppendText("\n");
+ }
+
+ // Self-Care
+ if (!string.IsNullOrWhiteSpace(result.SelfCareAdvice))
+ {
+ AppendHeader("🏠 Self-Care Advice");
+ AppendBody(result.SelfCareAdvice);
+ rtb.AppendText("\n");
+ }
+
+ // Disclaimer
+ rtb.SelectionFont = new Font(rtb.Font.FontFamily, 8.5f, FontStyle.Italic);
+ rtb.SelectionColor = Color.Gray;
+ string disclaimer = !string.IsNullOrWhiteSpace(result.Disclaimer)
+ ? result.Disclaimer
+ : "⚠️ This information is for educational purposes only. Always consult a healthcare professional.";
+ rtb.AppendText($"\n{disclaimer}\n");
+
+ // Timing
+ _aiTimingLabel.Text = $"AI response: {result.ElapsedMs} ms | Model: {_ollamaService?.ModelName ?? "?"}";
+
+ // Scroll to top
+ rtb.SelectionStart = 0;
+ rtb.ScrollToCaret();
+ }
+
+ /// Apply Ollama translations.
+ private void ApplyOllamaTranslations()
+ {
+ var t = _translationService;
+ if (t == null) return;
+
+ _grpAi.Text = t.T("AiAssistantTitle") ?? "🤖 AI Diagnostic Assistant (Ollama)";
+ _aiDiagnoseButton.Text = t.T("AiDiagnose") ?? "🧠 AI Diagnosis";
+ _aiMedsButton.Text = t.T("AiMedications") ?? "💊 AI Medications";
+ _autoAiCheck.Text = t.T("AutoAi") ?? "Auto AI";
+ }
+ }
+}
diff --git a/UI/MainForm.Results.cs b/UI/MainForm.Results.cs
new file mode 100644
index 0000000..308cdb9
--- /dev/null
+++ b/UI/MainForm.Results.cs
@@ -0,0 +1,187 @@
+using System;
+using System.Collections.Generic;
+using System.Drawing;
+using System.Linq;
+using System.Windows.Forms;
+using SymptomCheckerApp.Models;
+
+namespace SymptomCheckerApp.UI
+{
+ // Results rendering: rebuild, draw, measure
+ public partial class MainForm
+ {
+ private void RebuildResultsListItems()
+ {
+ _resultsList.Items.Clear();
+ _resultIndexMap.Clear();
+ var t = _translationService;
+ if (_lastResults == null || _lastResults.Count == 0)
+ {
+ _resultsList.Items.Add(t?.T("NoMatches") ?? "No matching conditions found based on the current selection.");
+ _resultIndexMap.Add(-1);
+ return;
+ }
+
+ // If categories service is available, group results by primary category (max overlap)
+ var categories = _categoriesService?.GetAllCategories()?.ToList() ?? new List();
+ if (categories.Count == 0)
+ {
+ // Fallback: flat list
+ for (int i = 0; i < _lastResults.Count; i++)
+ {
+ var m = _lastResults[i];
+ string name = t?.Condition(m.Name) ?? m.Name;
+ string scoreLabel = t?.T("Score") ?? "Score:";
+ string matchesLabel = t?.T("Matches") ?? "matches:";
+ _resultsList.Items.Add($"{name} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})");
+ _resultIndexMap.Add(i);
+ }
+ _resultsList.Invalidate();
+ return;
+ }
+
+ // Use cached sets
+ var catSets = _categorySetsCache;
+
+ // Group results
+ var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+ for (int i = 0; i < _lastResults.Count; i++)
+ {
+ var m = _lastResults[i];
+ string displayName = t?.Condition(m.Name) ?? m.Name;
+ string scoreLabel = t?.T("Score") ?? "Score:";
+ string matchesLabel = t?.T("Matches") ?? "matches:";
+ string line = $"{displayName} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})";
+
+ // Determine best matching category
+ string bestCat = "Other";
+ if (_service != null && _service.TryGetCondition(m.Name, out var c) && c != null)
+ {
+ int best = -1;
+ foreach (var kvp in catSets)
+ {
+ int overlap = 0;
+ foreach (var s in c.Symptoms)
+ {
+ if (kvp.Value.Contains(s)) overlap++;
+ }
+ if (overlap > best)
+ {
+ best = overlap; bestCat = kvp.Key;
+ }
+ }
+ }
+ var dispCat = t?.Category(bestCat) ?? bestCat;
+ if (!grouped.TryGetValue(dispCat, out var list))
+ {
+ list = new List<(int, string, double)>();
+ grouped[dispCat] = list;
+ }
+ list.Add((i, line, m.Score));
+ }
+
+ // Stable group order by localized category name
+ foreach (var g in grouped.OrderBy(k => k.Key, StringComparer.CurrentCulture))
+ {
+ _resultsList.Items.Add(new GroupHeader(g.Key, g.Key));
+ _resultIndexMap.Add(-1);
+ foreach (var item in g.Value)
+ {
+ _resultsList.Items.Add(item.line);
+ _resultIndexMap.Add(item.resultIndex);
+ }
+ }
+ _resultsList.Invalidate();
+ }
+
+ private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e)
+ {
+ e.DrawBackground();
+ if (e.Index < 0 || e.Index >= _resultsList.Items.Count)
+ {
+ return;
+ }
+
+ string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty;
+
+ // Render group headers differently
+ if (_resultsList.Items[e.Index] is GroupHeader)
+ {
+ var boldFont = new Font(e.Font ?? SystemFonts.DefaultFont, FontStyle.Bold);
+ bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
+ var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
+ if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
+ using (var back = new SolidBrush(Color.FromArgb(245, 245, 245)))
+ {
+ e.Graphics.FillRectangle(back, e.Bounds);
+ }
+ var rectH = e.Bounds; rectH.Inflate(-6, -2);
+ TextRenderer.DrawText(e.Graphics, text, boldFont, rectH, Color.DimGray, flagsH);
+ e.DrawFocusRectangle();
+ return;
+ }
+
+ bool isTop = false;
+ // Only highlight if we have actual results and the item corresponds to a scored match
+ if (_lastResults != null && _lastResults.Count > 0)
+ {
+ int resultIdx = (e.Index >= 0 && e.Index < _resultIndexMap.Count) ? _resultIndexMap[e.Index] : -1;
+ if (resultIdx >= 0 && resultIdx < _lastResults.Count)
+ {
+ double max = _lastResults[0].Score;
+ double sc = _lastResults[resultIdx].Score;
+ isTop = Math.Abs(sc - max) < 1e-9 && max > 0;
+ }
+ }
+
+ Color backColor = isTop ? Color.FromArgb(230, 255, 230) : e.BackColor; // light green for top
+ Color foreColor = isTop ? Color.DarkGreen : e.ForeColor;
+
+ using (var backBrush = new SolidBrush(backColor))
+ using (var foreBrush = new SolidBrush(foreColor))
+ {
+ e.Graphics.FillRectangle(backBrush, e.Bounds);
+ var font = e.Font ?? SystemFonts.DefaultFont;
+ bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
+ var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
+ if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
+ var rect = e.Bounds;
+ rect.Inflate(-4, -2);
+ TextRenderer.DrawText(e.Graphics, text, font, rect, foreColor, flags);
+ }
+
+ e.DrawFocusRectangle();
+ }
+
+ private void ResultsList_MeasureItem(object? sender, MeasureItemEventArgs e)
+ {
+ if (e.Index < 0 || e.Index >= _resultsList.Items.Count)
+ {
+ e.ItemHeight = 22;
+ return;
+ }
+ string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty;
+ if (_resultsList.Items[e.Index] is GroupHeader)
+ {
+ var boldFont = new Font(_resultsList.Font ?? SystemFonts.DefaultFont, FontStyle.Bold);
+ bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
+ var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
+ if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
+ var widthH = Math.Max(10, _resultsList.ClientSize.Width - 12);
+ var sizeH = TextRenderer.MeasureText(text, boldFont, new Size(widthH, int.MaxValue), flagsH);
+ e.ItemHeight = Math.Max(22, sizeH.Height + 6);
+ e.ItemWidth = widthH;
+ return;
+ }
+ var font = _resultsList.Font ?? SystemFonts.DefaultFont;
+ bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
+ var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
+ if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
+ var width = Math.Max(10, _resultsList.ClientSize.Width - 8);
+ var size = TextRenderer.MeasureText(text, font, new Size(width, int.MaxValue), flags);
+ int min = 22;
+ e.ItemHeight = Math.Max(min, size.Height + 4);
+ e.ItemWidth = width;
+ }
+ }
+}
diff --git a/UI/MainForm.Session.cs b/UI/MainForm.Session.cs
new file mode 100644
index 0000000..3c8ae1b
--- /dev/null
+++ b/UI/MainForm.Session.cs
@@ -0,0 +1,200 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Windows.Forms;
+using System.Text.Json;
+
+namespace SymptomCheckerApp.UI
+{
+ // Session save/load and settings profile management
+ public partial class MainForm
+ {
+ private class SessionData
+ {
+ public List SelectedSymptoms { get; set; } = new();
+ public string? Model { get; set; }
+ public int ThresholdPercent { get; set; }
+ public int MinMatch { get; set; }
+ public int TopK { get; set; }
+ public string? Language { get; set; }
+ }
+
+ private class SettingsProfile
+ {
+ public string? Language { get; set; }
+ public bool DarkMode { get; set; }
+ public string? Model { get; set; }
+ public int ThresholdPercent { get; set; }
+ public int MinMatch { get; set; }
+ public int TopK { get; set; }
+ public bool ShowOnlyCategory { get; set; }
+ public string? SelectedCategory { get; set; }
+ }
+
+ private void SaveSession()
+ {
+ try
+ {
+ var sfd = new SaveFileDialog { Filter = "Session JSON (*.json)|*.json", FileName = "session.json" };
+ if (sfd.ShowDialog(this) != DialogResult.OK) return;
+ var data = new SessionData
+ {
+ SelectedSymptoms = _checkedSymptoms.ToList(),
+ Model = _modelSelector.SelectedItem?.ToString(),
+ ThresholdPercent = (int)_threshold.Value,
+ MinMatch = (int)_minMatch.Value,
+ TopK = (int)_topK.Value,
+ Language = (_languageSelector.SelectedItem as LangItem)?.Code
+ };
+ var json = JsonSerializer.Serialize(data, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(sfd.FileName, json);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void LoadSession()
+ {
+ try
+ {
+ var ofd = new OpenFileDialog { Filter = "Session JSON (*.json)|*.json" };
+ if (ofd.ShowDialog(this) != DialogResult.OK) return;
+ var json = File.ReadAllText(ofd.FileName);
+ var data = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (data == null) return;
+ _checkedSymptoms = new HashSet(data.SelectedSymptoms ?? new List(), StringComparer.OrdinalIgnoreCase);
+ if (!string.IsNullOrEmpty(data.Language))
+ {
+ for (int i = 0; i < _languageSelector.Items.Count; i++)
+ {
+ if (_languageSelector.Items[i] is LangItem li &&
+ data.Language is string lang &&
+ string.Equals(li.Code, lang, StringComparison.OrdinalIgnoreCase))
+ { _languageSelector.SelectedIndex = i; break; }
+ }
+ }
+ if (!string.IsNullOrEmpty(data.Model))
+ {
+ for (int i = 0; i < _modelSelector.Items.Count; i++)
+ {
+ if (_modelSelector.Items[i]?.ToString()?.Equals(data.Model, StringComparison.OrdinalIgnoreCase) == true)
+ { _modelSelector.SelectedIndex = i; break; }
+ }
+ }
+ _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, data.ThresholdPercent));
+ _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, data.MinMatch));
+ _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, data.TopK));
+ RefreshSymptomList();
+ UpdateCheckButtonEnabled();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void ResetSettings()
+ {
+ if (_settingsService == null) return;
+ var confirm = MessageBox.Show(this, _translationService?.T("ConfirmResetSettings") ?? "Reset all settings to defaults?", _translationService?.T("ResetSettings") ?? "Reset Settings", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
+ if (confirm != DialogResult.Yes) return;
+ _settingsService.Reset();
+ _darkModeToggle.Checked = _settingsService.Settings.DarkMode;
+ for (int i = 0; i < _modelSelector.Items.Count; i++)
+ {
+ var it = _modelSelector.Items[i]?.ToString();
+ if (it != null && _settingsService.Settings.Model != null && string.Equals(it, _settingsService.Settings.Model, StringComparison.OrdinalIgnoreCase)) { _modelSelector.SelectedIndex = i; break; }
+ }
+ _threshold.Value = _settingsService.Settings.ThresholdPercent;
+ _minMatch.Value = _settingsService.Settings.MinMatch;
+ _topK.Value = _settingsService.Settings.TopK;
+ _filterBox.Text = _settingsService.Settings.FilterText ?? string.Empty;
+ _showOnlyCategory.Checked = _settingsService.Settings.ShowOnlyCategory;
+ RefreshSymptomList();
+ UpdateDecisionRules();
+ UpdatePercRule();
+ }
+
+ private void SaveSettingsProfile()
+ {
+ if (_settingsService == null) return;
+ try
+ {
+ var sfd = new SaveFileDialog { Filter = "Settings Profile (*.json)|*.json", FileName = "settings_profile.json" };
+ if (sfd.ShowDialog(this) != DialogResult.OK) return;
+ var profile = new SettingsProfile
+ {
+ Language = _settingsService.Settings.Language,
+ DarkMode = _settingsService.Settings.DarkMode,
+ Model = _settingsService.Settings.Model,
+ ThresholdPercent = _settingsService.Settings.ThresholdPercent,
+ MinMatch = _settingsService.Settings.MinMatch,
+ TopK = _settingsService.Settings.TopK,
+ ShowOnlyCategory = _settingsService.Settings.ShowOnlyCategory,
+ SelectedCategory = _settingsService.Settings.SelectedCategory
+ };
+ var json = JsonSerializer.Serialize(profile, new JsonSerializerOptions { WriteIndented = true });
+ File.WriteAllText(sfd.FileName, json);
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+
+ private void LoadSettingsProfile()
+ {
+ if (_settingsService == null) return;
+ try
+ {
+ var ofd = new OpenFileDialog { Filter = "Settings Profile (*.json)|*.json" };
+ if (ofd.ShowDialog(this) != DialogResult.OK) return;
+ var json = File.ReadAllText(ofd.FileName);
+ var profile = JsonSerializer.Deserialize(json, new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
+ if (profile == null) return;
+ _settingsService.Settings.Language = profile.Language;
+ _settingsService.Settings.DarkMode = profile.DarkMode;
+ _settingsService.Settings.Model = profile.Model;
+ _settingsService.Settings.ThresholdPercent = profile.ThresholdPercent;
+ _settingsService.Settings.MinMatch = profile.MinMatch;
+ _settingsService.Settings.TopK = profile.TopK;
+ _settingsService.Settings.ShowOnlyCategory = profile.ShowOnlyCategory;
+ _settingsService.Settings.SelectedCategory = profile.SelectedCategory;
+ _settingsService.Save();
+ _darkModeToggle.Checked = profile.DarkMode;
+ for (int i = 0; i < _modelSelector.Items.Count; i++)
+ {
+ if (_modelSelector.Items[i]?.ToString()?.Equals(profile.Model, StringComparison.OrdinalIgnoreCase) == true) { _modelSelector.SelectedIndex = i; break; }
+ }
+ _threshold.Value = Math.Max(_threshold.Minimum, Math.Min(_threshold.Maximum, profile.ThresholdPercent));
+ _minMatch.Value = Math.Max(_minMatch.Minimum, Math.Min(_minMatch.Maximum, profile.MinMatch));
+ _topK.Value = Math.Max(_topK.Minimum, Math.Min(_topK.Maximum, profile.TopK));
+ _showOnlyCategory.Checked = profile.ShowOnlyCategory;
+ if (!string.IsNullOrEmpty(profile.SelectedCategory))
+ {
+ for (int i = 0; i < _categorySelector.Items.Count; i++)
+ {
+ if (_categorySelector.Items[i] is CatItem ci && ci.Canonical.Equals(profile.SelectedCategory, StringComparison.OrdinalIgnoreCase)) { _categorySelector.SelectedIndex = i; break; }
+ }
+ }
+ if (!string.IsNullOrEmpty(profile.Language))
+ {
+ for (int i = 0; i < _languageSelector.Items.Count; i++)
+ {
+ if (_languageSelector.Items[i] is LangItem li && li.Code.Equals(profile.Language, StringComparison.OrdinalIgnoreCase)) { _languageSelector.SelectedIndex = i; break; }
+ }
+ }
+ RefreshSymptomList();
+ UpdateDecisionRules();
+ UpdatePercRule();
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show(this, ex.Message, _translationService?.T("Error") ?? "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
+ }
+ }
+ }
+}
diff --git a/UI/MainForm.Theme.cs b/UI/MainForm.Theme.cs
new file mode 100644
index 0000000..132ad51
--- /dev/null
+++ b/UI/MainForm.Theme.cs
@@ -0,0 +1,101 @@
+using System;
+using System.Drawing;
+using System.Windows.Forms;
+
+namespace SymptomCheckerApp.UI
+{
+ // Theme application logic
+ public partial class MainForm
+ {
+ private void ApplyTheme()
+ {
+ bool dark = _darkModeToggle.Checked;
+ Color back = dark ? Color.FromArgb(32, 32, 32) : SystemColors.Window;
+ Color fore = dark ? Color.Gainsboro : SystemColors.WindowText;
+ Color panel = dark ? Color.FromArgb(24, 24, 24) : SystemColors.Control;
+
+ this.BackColor = panel;
+ foreach (Control c in this.Controls)
+ {
+ ApplyThemeToControl(c, back, fore, panel, dark);
+ }
+ _resultsList.Invalidate();
+ }
+
+ private void ApplyThemeToControl(Control c, Color back, Color fore, Color panel, bool dark)
+ {
+ switch (c)
+ {
+ case SplitContainer sc:
+ sc.BackColor = panel;
+ ApplyThemeToControl(sc.Panel1, back, fore, panel, dark);
+ ApplyThemeToControl(sc.Panel2, back, fore, panel, dark);
+ break;
+ case TableLayoutPanel tl:
+ tl.BackColor = panel;
+ foreach (Control child in tl.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
+ break;
+ case FlowLayoutPanel fl:
+ fl.BackColor = panel;
+ foreach (Control child in fl.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
+ break;
+ case CheckedListBox clb:
+ clb.BackColor = back; clb.ForeColor = fore;
+ break;
+ case ListBox lb:
+ lb.BackColor = back; lb.ForeColor = fore;
+ break;
+ case TextBox tb:
+ tb.BackColor = back; tb.ForeColor = fore;
+ break;
+ case ComboBox cb:
+ cb.BackColor = back; cb.ForeColor = fore;
+ break;
+ case Label lbl:
+ lbl.BackColor = panel; lbl.ForeColor = fore;
+ break;
+ case GroupBox gb:
+ gb.BackColor = panel; gb.ForeColor = fore;
+ foreach (Control child in gb.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
+ break;
+ case Button btn:
+ if (btn == _checkButton)
+ {
+ btn.BackColor = dark ? Color.FromArgb(40, 167, 69) : Color.MediumSeaGreen;
+ btn.ForeColor = Color.White;
+ try { btn.FlatAppearance.BorderSize = 1; btn.FlatAppearance.BorderColor = dark ? Color.FromArgb(30, 120, 50) : Color.SeaGreen; } catch { }
+ }
+ else
+ {
+ btn.BackColor = dark ? Color.FromArgb(60, 60, 60) : SystemColors.Control;
+ btn.ForeColor = fore;
+ }
+ break;
+ case CheckBox chk:
+ chk.BackColor = panel; chk.ForeColor = fore;
+ break;
+ case NumericUpDown nud:
+ nud.BackColor = back; nud.ForeColor = fore;
+ break;
+ case Control generic:
+ generic.BackColor = panel; generic.ForeColor = fore;
+ break;
+ }
+ // Triage banner specific colors
+ if (c == _triageBanner)
+ {
+ _triageBanner.BackColor = dark ? Color.FromArgb(64, 48, 0) : Color.FromArgb(255, 245, 230);
+ _triageBanner.ForeColor = dark ? Color.Khaki : Color.FromArgb(120, 60, 0);
+ }
+ }
+
+ private void ApplyRtl(Control root, bool rtl)
+ {
+ if (root is Form f)
+ {
+ try { f.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { }
+ try { f.RightToLeftLayout = rtl; } catch { }
+ }
+ }
+ }
+}
diff --git a/UI/MainForm.cs b/UI/MainForm.cs
index 22c2654..4ff2f72 100644
--- a/UI/MainForm.cs
+++ b/UI/MainForm.cs
@@ -11,7 +11,7 @@
namespace SymptomCheckerApp.UI
{
- public class MainForm : Form
+ public partial class MainForm : Form
{
private class ListItem
{
@@ -59,6 +59,9 @@ public GroupHeader(string canonicalCategory, string display)
public override string ToString() => Display;
}
+ // Debounce timer for filter text input
+ private readonly System.Windows.Forms.Timer _filterDebounce = new System.Windows.Forms.Timer { Interval = 250 };
+
private readonly CheckedListBox _symptomList = new CheckedListBox();
private readonly Button _checkButton = new Button();
private readonly Button _exitButton = new Button();
@@ -76,6 +79,8 @@ public GroupHeader(string canonicalCategory, string display)
private readonly CheckBox _showOnlyCategory = new CheckBox();
private readonly Button _selectVisibleButton = new Button();
private readonly Button _clearVisibleButton = new Button();
+ private readonly Button _selectAllButton = new Button();
+ private readonly Button _deselectAllButton = new Button();
private readonly Button _saveSessionButton = new Button();
private readonly Button _loadSessionButton = new Button();
private readonly Button _resetSettingsButton = new Button();
@@ -158,8 +163,12 @@ public GroupHeader(string canonicalCategory, string display)
public MainForm()
{
Text = "Symptom Checker (Educational)";
- Width = 900;
- Height = 600;
+ AutoScaleMode = AutoScaleMode.Dpi;
+ // Responsive: size relative to screen, with sensible minimum
+ var screen = Screen.PrimaryScreen?.WorkingArea ?? new Rectangle(0, 0, 1920, 1080);
+ Width = Math.Max(800, (int)(screen.Width * 0.7));
+ Height = Math.Max(550, (int)(screen.Height * 0.75));
+ MinimumSize = new Size(700, 480);
StartPosition = FormStartPosition.CenterScreen;
// Accessibility: keyboard shortcuts
@@ -185,19 +194,48 @@ public MainForm()
{
try
{
- var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit");
- if (sc != null)
+ var sc = FindControl(this, "_mainSplit");
+ if (sc != null && sc.Width > 0)
{
- int minRight = 420; // ensure clear right menu area at launch
- if (sc.Width - sc.SplitterDistance < minRight)
- {
- sc.SplitterDistance = Math.Max(sc.Panel1MinSize, sc.Width - minRight);
- }
+ // Responsive: ensure ~30/70 split on first show
+ int target = (int)(sc.Width * 0.30);
+ if (target >= sc.Panel1MinSize && (sc.Width - target - sc.SplitterWidth) >= sc.Panel2MinSize)
+ sc.SplitterDistance = target;
+ }
+ // Set vertical split proportionally
+ var vs = FindControl(this, "_mainVerticalSplit");
+ if (vs != null && vs.Height > 0)
+ {
+ int targetV = Math.Max(vs.Panel1MinSize, (int)(vs.Height * 0.60));
+ if ((vs.Height - targetV - vs.SplitterWidth) >= vs.Panel2MinSize)
+ vs.SplitterDistance = targetV;
}
}
catch { }
_filterBox.Focus();
};
+ // Re-proportion splits on resize
+ this.Resize += (s, e) =>
+ {
+ try
+ {
+ var sc = FindControl(this, "_mainSplit");
+ if (sc != null && sc.Width > 0)
+ {
+ int target = (int)(sc.Width * 0.30);
+ if (target >= sc.Panel1MinSize && (sc.Width - target) >= sc.Panel2MinSize)
+ sc.SplitterDistance = target;
+ }
+ var vs = FindControl(this, "_mainVerticalSplit");
+ if (vs != null && vs.Height > 0)
+ {
+ int target = (int)(vs.Height * 0.60);
+ if (target >= vs.Panel1MinSize && (vs.Height - target) >= vs.Panel2MinSize)
+ vs.SplitterDistance = target;
+ }
+ }
+ catch { }
+ };
this.FormClosing += (s, e) =>
{
try { _settingsService?.Save(); } catch { }
@@ -216,11 +254,26 @@ private void InitializeLayout()
{
Dock = DockStyle.Fill,
Orientation = Orientation.Vertical,
- SplitterDistance = 400,
Name = "_mainSplit"
};
- // Ensure the right panel has enough space by default
- try { split.Panel2MinSize = 360; split.Panel1MinSize = 220; split.SplitterWidth = 6; } catch { }
+ // Responsive: set splitter as percentage of width
+ try
+ {
+ int p1Min = ScaleX(180);
+ int p2Min = ScaleX(280);
+ int splW = ScaleX(5);
+ // Ensure the container is wide enough before setting min sizes
+ int requiredWidth = p1Min + p2Min + splW + 1;
+ if (split.Width < requiredWidth)
+ split.Width = Math.Max(requiredWidth, this.ClientSize.Width);
+ split.Panel1MinSize = p1Min;
+ split.Panel2MinSize = p2Min;
+ split.SplitterWidth = splW;
+ int desired = Math.Max(p1Min, (int)(this.ClientSize.Width * 0.30));
+ int maxDist = split.Width - p2Min - splW;
+ split.SplitterDistance = Math.Max(p1Min, Math.Min(desired, maxDist));
+ }
+ catch { try { split.SplitterDistance = 300; } catch { } }
// Collapse/expand button (overlay small)
var collapseBtn = new Button
{
@@ -245,7 +298,12 @@ private void InitializeLayout()
{
split.Panel1Collapsed = false;
// Reassign a reasonable default width
- try { split.SplitterDistance = Math.Max(250, Width / 3); } catch { }
+ try
+ {
+ int t = Math.Max(split.Panel1MinSize, (int)(split.Width * 0.30));
+ int m = split.Width - split.Panel2MinSize - split.SplitterWidth;
+ split.SplitterDistance = Math.Max(split.Panel1MinSize, Math.Min(t, m));
+ } catch { }
collapseBtn.Text = "≪";
collapsed = false;
try { _settingsService!.Settings.LeftPanelCollapsed = false; _settingsService.Save(); } catch { }
@@ -277,12 +335,20 @@ private void InitializeLayout()
_lblFilter.Text = "Filter:";
_lblFilter.AutoSize = true;
_lblFilter.Padding = new Padding(0, 6, 0, 0);
- _filterBox.Width = 220;
+ _filterBox.Width = ScaleX(180);
+ _filterBox.Anchor = AnchorStyles.Left | AnchorStyles.Right;
_filterBox.TabIndex = 0;
_filterBox.AccessibleName = "Filter symptoms";
_filterBox.AccessibleDescription = "Type text to filter the symptoms list";
_filterBox.TextChanged += (s, e) =>
{
+ // Debounce: restart the timer on each keystroke
+ _filterDebounce.Stop();
+ _filterDebounce.Start();
+ };
+ _filterDebounce.Tick += (s, e) =>
+ {
+ _filterDebounce.Stop();
RefreshSymptomList();
if (_settingsService != null)
{
@@ -301,11 +367,21 @@ private void InitializeLayout()
_clearVisibleButton.TabIndex = 2;
_clearVisibleButton.Click += (s, e) => ClearVisibleSymptoms();
+ _selectAllButton.Text = "Select All";
+ _selectAllButton.AutoSize = true;
+ _selectAllButton.AccessibleName = "Select all symptoms";
+ _selectAllButton.Click += (s, e) => SelectAllSymptoms();
+
+ _deselectAllButton.Text = "Deselect All";
+ _deselectAllButton.AutoSize = true;
+ _deselectAllButton.AccessibleName = "Deselect all symptoms";
+ _deselectAllButton.Click += (s, e) => DeselectAllSymptoms();
+
_lblCategory.Text = "Category:";
_lblCategory.AutoSize = true;
_lblCategory.Padding = new Padding(10, 6, 0, 0);
_categorySelector.DropDownStyle = ComboBoxStyle.DropDownList;
- _categorySelector.Width = 180;
+ _categorySelector.Width = ScaleX(150);
_categorySelector.AccessibleName = "Category filter";
_categorySelector.TabIndex = 3;
_categorySelector.SelectedIndexChanged += (s, e) =>
@@ -347,6 +423,8 @@ private void InitializeLayout()
filterBar.Controls.Add(_filterBox);
filterBar.Controls.Add(_selectVisibleButton);
filterBar.Controls.Add(_clearVisibleButton);
+ filterBar.Controls.Add(_selectAllButton);
+ filterBar.Controls.Add(_deselectAllButton);
_saveSessionButton.Text = "Save";
_saveSessionButton.AutoSize = true;
_saveSessionButton.Click += (s, e) => SaveSession();
@@ -396,8 +474,8 @@ private void InitializeLayout()
FlowDirection = FlowDirection.LeftToRight,
Padding = new Padding(3)
};
- var vitalsRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true };
- var rulesRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true };
+ var vitalsRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = true };
+ var rulesRow = new FlowLayoutPanel { Dock = DockStyle.Fill, AutoSize = true, WrapContents = true };
_modelSelector.DropDownStyle = ComboBoxStyle.DropDownList;
_modelSelector.Items.AddRange(new object[] { DetectionModel.Jaccard, DetectionModel.Cosine, DetectionModel.NaiveBayes });
@@ -421,7 +499,7 @@ private void InitializeLayout()
_threshold.Maximum = 100;
_threshold.DecimalPlaces = 0;
_threshold.Value = 0;
- _threshold.Width = 80;
+ _threshold.Width = ScaleX(65);
_threshold.AccessibleName = "Score threshold percent";
_threshold.TabIndex = 11;
_threshold.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.ThresholdPercent = (int)_threshold.Value; _settingsService.Save(); } };
@@ -433,7 +511,7 @@ private void InitializeLayout()
_minMatch.Minimum = 0; // 0 means any match count
_minMatch.Maximum = 10;
_minMatch.Value = 1;
- _minMatch.Width = 60;
+ _minMatch.Width = ScaleX(55);
_minMatch.AccessibleName = "Minimum matching symptoms";
_minMatch.TabIndex = 12;
_minMatch.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.MinMatch = (int)_minMatch.Value; _settingsService.Save(); } };
@@ -442,7 +520,7 @@ private void InitializeLayout()
_topK.Minimum = 0; // 0 = unlimited
_topK.Maximum = 1000;
_topK.Value = 0;
- _topK.Width = 70;
+ _topK.Width = ScaleX(60);
_topK.AccessibleName = "Top K results";
_topK.TabIndex = 13;
_topK.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.TopK = (int)_topK.Value; _settingsService.Save(); } };
@@ -450,7 +528,7 @@ private void InitializeLayout()
// Category weighting UI (model tuning)
_weightCatSelector.DropDownStyle = ComboBoxStyle.DropDownList;
- _weightCatSelector.Width = 130;
+ _weightCatSelector.Width = ScaleX(110);
_weightCatSelector.AccessibleName = "Category weight selector";
_weightCatSelector.TabIndex = 13;
_weightCatSelector.SelectedIndexChanged += (s, e) =>
@@ -473,7 +551,7 @@ private void InitializeLayout()
_weightValue.Minimum = 10; // 0.1x
_weightValue.Maximum = 500; // 5x
_weightValue.Value = 100; // 1x
- _weightValue.Width = 70;
+ _weightValue.Width = ScaleX(60);
_weightValue.Increment = 10;
_weightValue.AccessibleName = "Category weight percent";
_weightValue.TabIndex = 14;
@@ -506,7 +584,7 @@ private void InitializeLayout()
_nbTempValue.DecimalPlaces = 2;
_nbTempValue.Increment = 5; // 0.05
_nbTempValue.Value = 100; // 1.00
- _nbTempValue.Width = 70;
+ _nbTempValue.Width = ScaleX(60);
_nbTempValue.AccessibleName = "Naive Bayes temperature";
_nbTempValue.TabIndex = 18;
_nbTempEnable.CheckedChanged += (s, e) =>
@@ -538,7 +616,7 @@ private void InitializeLayout()
_lblLanguage.Text = "Language:"; _lblLanguage.AutoSize = true; _lblLanguage.Padding = new Padding(10, 6, 0, 0);
_languageSelector.DropDownStyle = ComboBoxStyle.DropDownList;
- _languageSelector.Width = 120;
+ _languageSelector.Width = ScaleX(100);
_languageSelector.AccessibleName = "Language selector";
_languageSelector.TabIndex = 19;
_languageSelector.SelectedIndexChanged += (s, e) => OnLanguageChanged();
@@ -621,29 +699,29 @@ private void InitializeLayout()
// Vitals setup and defaults
_lblVitals.Text = "Vitals:"; _lblVitals.AutoSize = true; _lblVitals.Padding = new Padding(10, 6, 0, 0);
_lblTemp.Text = "Temp (°C):"; _lblTemp.AutoSize = true; _lblTemp.Padding = new Padding(10, 6, 0, 0);
- _numTempC.DecimalPlaces = 1; _numTempC.Increment = 0.1M; _numTempC.Minimum = 30; _numTempC.Maximum = 45; _numTempC.Width = 70; _numTempC.AccessibleName = "Temperature Celsius"; _numTempC.TabIndex = 40;
+ _numTempC.DecimalPlaces = 1; _numTempC.Increment = 0.1M; _numTempC.Minimum = 30; _numTempC.Maximum = 45; _numTempC.Width = ScaleX(60); _numTempC.AccessibleName = "Temperature Celsius"; _numTempC.TabIndex = 40;
_numTempC.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.TempC = (double)_numTempC.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_lblHR.Text = "HR (bpm):"; _lblHR.AutoSize = true; _lblHR.Padding = new Padding(10, 6, 0, 0);
- _numHR.Minimum = 20; _numHR.Maximum = 240; _numHR.Width = 70; _numHR.AccessibleName = "Heart rate"; _numHR.TabIndex = 41;
+ _numHR.Minimum = 20; _numHR.Maximum = 240; _numHR.Width = ScaleX(60); _numHR.AccessibleName = "Heart rate"; _numHR.TabIndex = 41;
_numHR.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.HeartRate = (int)_numHR.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_lblRR.Text = "RR (/min):"; _lblRR.AutoSize = true; _lblRR.Padding = new Padding(10, 6, 0, 0);
- _numRR.Minimum = 4; _numRR.Maximum = 80; _numRR.Width = 70; _numRR.AccessibleName = "Respiratory rate"; _numRR.TabIndex = 42;
+ _numRR.Minimum = 4; _numRR.Maximum = 80; _numRR.Width = ScaleX(60); _numRR.AccessibleName = "Respiratory rate"; _numRR.TabIndex = 42;
_numRR.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.RespRate = (int)_numRR.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_lblBP.Text = "BP (SBP/DBP):"; _lblBP.AutoSize = true; _lblBP.Padding = new Padding(10, 6, 0, 0);
- _numSBP.Minimum = 50; _numSBP.Maximum = 260; _numSBP.Width = 70; _numSBP.AccessibleName = "Systolic blood pressure"; _numSBP.TabIndex = 43;
+ _numSBP.Minimum = 50; _numSBP.Maximum = 260; _numSBP.Width = ScaleX(60); _numSBP.AccessibleName = "Systolic blood pressure"; _numSBP.TabIndex = 43;
_numSBP.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.SystolicBP = (int)_numSBP.Value; _settingsService.Save(); } UpdateDecisionRules(); };
- _numDBP.Minimum = 30; _numDBP.Maximum = 160; _numDBP.Width = 70; _numDBP.AccessibleName = "Diastolic blood pressure"; _numDBP.TabIndex = 44;
+ _numDBP.Minimum = 30; _numDBP.Maximum = 160; _numDBP.Width = ScaleX(60); _numDBP.AccessibleName = "Diastolic blood pressure"; _numDBP.TabIndex = 44;
_numDBP.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.DiastolicBP = (int)_numDBP.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_lblSpO2.Text = "SpO₂ (%):"; _lblSpO2.AutoSize = true; _lblSpO2.Padding = new Padding(10, 6, 0, 0);
- _numSpO2.Minimum = 50; _numSpO2.Maximum = 100; _numSpO2.Width = 70; _numSpO2.AccessibleName = "Oxygen saturation"; _numSpO2.TabIndex = 45;
+ _numSpO2.Minimum = 50; _numSpO2.Maximum = 100; _numSpO2.Width = ScaleX(60); _numSpO2.AccessibleName = "Oxygen saturation"; _numSpO2.TabIndex = 45;
_numSpO2.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.SpO2 = (int)_numSpO2.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_lblWeight.Text = "Weight (kg):"; _lblWeight.AutoSize = true; _lblWeight.Padding = new Padding(10, 6, 0, 0);
- _numWeightKg.DecimalPlaces = 1; _numWeightKg.Increment = 0.5M; _numWeightKg.Minimum = 2; _numWeightKg.Maximum = 350; _numWeightKg.Width = 80; _numWeightKg.AccessibleName = "Weight kilograms"; _numWeightKg.TabIndex = 46;
+ _numWeightKg.DecimalPlaces = 1; _numWeightKg.Increment = 0.5M; _numWeightKg.Minimum = 2; _numWeightKg.Maximum = 350; _numWeightKg.Width = ScaleX(65); _numWeightKg.AccessibleName = "Weight kilograms"; _numWeightKg.TabIndex = 46;
_numWeightKg.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.WeightKg = (double)_numWeightKg.Value; _settingsService.Save(); } UpdateDecisionRules(); };
// We'll host filterBar inside a panel that enforces a maximum height with scrollbar if needed
@@ -658,7 +736,7 @@ private void InitializeLayout()
// After layout we can cap height dynamically
filterHost.Resize += (s, e) =>
{
- int maxH = 120; // max visible area for filter controls
+ int maxH = ScaleY(120); // max visible area for filter controls (DPI-scaled)
if (filterBar.Height > maxH)
{
filterHost.AutoScrollMinSize = new Size(filterBar.Width, filterBar.Height + 4);
@@ -721,7 +799,7 @@ private void InitializeLayout()
// Decision rules row (Centor/McIsaac)
_lblRules.Text = "Decision rules:"; _lblRules.AutoSize = true; _lblRules.Padding = new Padding(10, 6, 0, 0);
_lblAge.Text = "Age (years):"; _lblAge.AutoSize = true; _lblAge.Padding = new Padding(10, 6, 0, 0);
- _numAge.Minimum = 0; _numAge.Maximum = 120; _numAge.Value = 25; _numAge.Width = 70; _numAge.AccessibleName = "Age years"; _numAge.TabIndex = 50;
+ _numAge.Minimum = 0; _numAge.Maximum = 120; _numAge.Value = 25; _numAge.Width = ScaleX(60); _numAge.AccessibleName = "Age years"; _numAge.TabIndex = 50;
_numAge.ValueChanged += (s, e) => { if (_settingsService != null) { _settingsService.Settings.AgeYears = (int)_numAge.Value; _settingsService.Save(); } UpdateDecisionRules(); };
_grpCentor.Text = "Centor/McIsaac"; _grpCentor.AutoSize = true; _grpCentor.Padding = new Padding(6);
_centorFever.Text = "Fever (or Temp ≥38°C)"; _centorFever.AutoSize = true; _centorFever.Enabled = false;
@@ -774,6 +852,49 @@ private void InitializeLayout()
rightPanel.Controls.Add(_disclaimer, 0, 5);
split.Panel2.Controls.Add(rightPanel);
+ // AI / Ollama panel below the main split (bottom section)
+ var mainLayout = new SplitContainer
+ {
+ Dock = DockStyle.Fill,
+ Orientation = Orientation.Horizontal,
+ Name = "_mainVerticalSplit"
+ };
+ try
+ {
+ int p1MinV = ScaleY(200);
+ int p2MinV = ScaleY(140);
+ int splWV = ScaleY(5);
+ int requiredHeight = p1MinV + p2MinV + splWV + 1;
+ if (mainLayout.Height < requiredHeight)
+ mainLayout.Height = Math.Max(requiredHeight, this.ClientSize.Height);
+ mainLayout.Panel1MinSize = p1MinV;
+ mainLayout.Panel2MinSize = p2MinV;
+ mainLayout.SplitterWidth = splWV;
+ int desiredV = Math.Max(p1MinV, (int)(this.ClientSize.Height * 0.60));
+ int maxDistV = mainLayout.Height - p2MinV - splWV;
+ mainLayout.SplitterDistance = Math.Max(p1MinV, Math.Min(desiredV, maxDistV));
+ }
+ catch { try { mainLayout.SplitterDistance = 400; } catch { } }
+ // Add the existing horizontal split into the top panel
+ mainLayout.Panel1.Controls.Add(split);
+ // Bottom panel: TabControl with AI Diagnosis + Image Analysis tabs
+ var aiTabControl = new TabControl
+ {
+ Dock = DockStyle.Fill,
+ Name = "_aiTabControl"
+ };
+ var tabAiDiag = new TabPage("🤖 AI Diagnosis") { Name = "_tabAiDiag" };
+ var tabImageAnalysis = new TabPage("📷 Image Analysis") { Name = "_tabImageAnalysis" };
+ var tabBloodAnalysis = new TabPage("🔬 Blood Microscope") { Name = "_tabBloodAnalysis" };
+ InitializeOllamaPanel(tabAiDiag);
+ InitializeImageAnalysisPanel(tabImageAnalysis);
+ InitializeBloodAnalysisPanel(tabBloodAnalysis);
+ aiTabControl.TabPages.Add(tabAiDiag);
+ aiTabControl.TabPages.Add(tabImageAnalysis);
+ aiTabControl.TabPages.Add(tabBloodAnalysis);
+ mainLayout.Panel2.Controls.Add(aiTabControl);
+ Controls.Add(mainLayout);
+
// Settings path label placed at bottom-left of main form
_lblSettingsPath.Dock = DockStyle.Bottom;
_lblSettingsPath.AutoSize = true;
@@ -783,8 +904,6 @@ private void InitializeLayout()
_lblSettingsPath.AccessibleName = "Settings file path";
Controls.Add(_lblSettingsPath);
- Controls.Add(split);
-
// Context menu for results (Copy details, Print)
var miCopy = new ToolStripMenuItem("Copy Details", null, (s, e) => CopySelectedDetailsToClipboard());
var miPrint = new ToolStripMenuItem("Print Details", null, (s, e) => PrintSelectedDetails());
@@ -819,14 +938,20 @@ private void MainForm_Load(object? sender, EventArgs e)
var categoriesSchema = Path.Combine(baseDir, "schemas", "categories.schema.json");
var translationsData = Path.Combine(baseDir, "data", "translations.json");
var translationsSchema = Path.Combine(baseDir, "schemas", "translations.schema.json");
+ var synonymsData = Path.Combine(baseDir, "data", "synonyms.json");
+ var synonymsSchema = Path.Combine(baseDir, "schemas", "synonyms.schema.json");
var condErr = await SchemaValidator.ValidateAsync(conditionsData, conditionsSchema);
var catErr = await SchemaValidator.ValidateAsync(categoriesData, categoriesSchema);
var transErr = await SchemaValidator.ValidateAsync(translationsData, translationsSchema);
+ string? synErr = null;
+ if (File.Exists(synonymsData) && File.Exists(synonymsSchema))
+ synErr = await SchemaValidator.ValidateAsync(synonymsData, synonymsSchema);
var all = new List();
if (!string.IsNullOrEmpty(condErr)) all.Add(condErr);
if (!string.IsNullOrEmpty(catErr)) all.Add(catErr);
if (!string.IsNullOrEmpty(transErr)) all.Add(transErr);
+ if (!string.IsNullOrEmpty(synErr)) all.Add(synErr);
if (all.Count > 0)
{
var msg = string.Join("\n\n", all);
@@ -925,7 +1050,7 @@ private void MainForm_Load(object? sender, EventArgs e)
// Restore left panel collapsed state
try
{
- var sc = this.Controls.OfType().FirstOrDefault(c => c.Name == "_mainSplit");
+ var sc = FindControl(this, "_mainSplit");
if (sc != null && _settingsService?.Settings.LeftPanelCollapsed == true)
{
sc.Panel1Collapsed = true; if (_collapseBtn != null) _collapseBtn.Text = "≫";
@@ -996,10 +1121,20 @@ private void MainForm_Load(object? sender, EventArgs e)
// Restore filter and category visibility
try { _filterBox.Text = _settingsService?.Settings.FilterText ?? string.Empty; } catch { }
try { _showOnlyCategory.Checked = _settingsService?.Settings.ShowOnlyCategory ?? false; } catch { }
+ // Restore Ollama settings
+ try
+ {
+ if (!string.IsNullOrEmpty(_settingsService?.Settings.OllamaUrl))
+ _aiUrlBox.Text = _settingsService.Settings.OllamaUrl;
+ _autoAiCheck.Checked = _settingsService?.Settings.AutoAi ?? false;
+ }
+ catch { }
ApplyTheme();
RefreshSymptomList();
UpdateDecisionRules();
UpdatePercRule();
+ // Initialize Ollama connection (non-blocking)
+ _ = InitializeOllamaAsync();
}
catch (Exception ex)
{
@@ -1063,6 +1198,8 @@ private void ApplyTranslations()
_lblFilter.Text = t.T("Filter");
_selectVisibleButton.Text = t.T("SelectVisible");
_clearVisibleButton.Text = t.T("ClearVisible");
+ _selectAllButton.Text = t.T("SelectAll") ?? _selectAllButton.Text;
+ _deselectAllButton.Text = t.T("DeselectAll") ?? _deselectAllButton.Text;
_lblCategory.Text = t.T("Category");
_selectCategoryButton.Text = t.T("SelectCategory");
_clearCategoryButton.Text = t.T("ClearCategory");
@@ -1156,6 +1293,12 @@ private void ApplyTranslations()
// Rebuild triage banner for current language
UpdateTriageBanner();
+ // Apply Ollama panel translations
+ ApplyOllamaTranslations();
+ // Apply Image Analysis panel translations
+ ApplyImageAnalysisTranslations();
+ // Apply Blood Analysis panel translations
+ ApplyBloodAnalysisTranslations();
}
else
{
@@ -1164,235 +1307,6 @@ private void ApplyTranslations()
}
}
- // Compute PERC rule result based on vitals, age, and history flags
- private void UpdatePercRule()
- {
- var t = _translationService;
- // Criteria
- bool ageOk = _numAge.Value < 50;
- bool hrOk = _numHR.Value < 100;
- bool spo2Ok = _numSpO2.Value >= 95;
- bool hemoptysisOk = !_percHemoptysis.Checked;
- bool estrogenOk = !_percEstrogen.Checked;
- bool priorOk = !_percPriorDvtPe.Checked;
- bool unilatOk = !_percUnilateralLeg.Checked;
- bool surgeryOk = !_percRecentSurgery.Checked;
- bool percNegative = ageOk && hrOk && spo2Ok && hemoptysisOk && estrogenOk && priorOk && unilatOk && surgeryOk;
-
- string neg = t?.T("PERC_Negative") ?? "PERC negative — PE unlikely if pretest probability is low.";
- string pos = t?.T("PERC_Positive") ?? "PERC positive — cannot rule out PE; consider further testing if suspicion persists.";
- _percResult.Text = percNegative ? neg : pos;
- }
-
- // Compute Centor and McIsaac scores based on current selections and vitals
- private void UpdateDecisionRules()
- {
- // Guard: group might not be initialized during early constructor runs
- if (_grpCentor == null) return;
- var t = _translationService;
- // Determine Centor components from current symptoms and vitals
- bool hasFever = false;
- try
- {
- if (_settingsService?.Settings.TempC.HasValue == true)
- hasFever = _settingsService!.Settings.TempC!.Value >= 38.0;
- }
- catch { }
- // Also infer from selected symptom 'Fever'
- hasFever = hasFever || _checkedSymptoms.Contains("Fever");
-
- bool tonsils = _checkedSymptoms.Contains("Sore Throat") || _checkedSymptoms.Contains("Tonsillar Exudates") || _checkedSymptoms.Contains("Tonsillar Swelling");
- // If we have a symptom like 'Tonsillar exudates/swelling' in translations only, we can't detect canonical; keep basic sore throat proxy
- bool nodes = _checkedSymptoms.Contains("Swollen Lymph Nodes");
- bool noCough = !_checkedSymptoms.Contains("Cough");
-
- // Update disabled checkboxes to reflect inferred state
- try { _centorFever.Checked = hasFever; } catch { }
- try { _centorTonsils.Checked = tonsils; } catch { }
- try { _centorNodes.Checked = nodes; } catch { }
- try { _centorNoCough.Checked = noCough; } catch { }
-
- int centor = 0;
- if (hasFever) centor++;
- if (tonsils) centor++;
- if (nodes) centor++;
- if (noCough) centor++;
-
- int age = (int)_numAge.Value;
- int ageAdj = 0;
- if (age < 15) ageAdj = 1; else if (age >= 45) ageAdj = -1;
- int mcIsaac = centor + ageAdj;
- if (mcIsaac < 0) mcIsaac = 0; if (mcIsaac > 5) mcIsaac = 5;
-
- string centorLabel = t?.T("CentorLabel") ?? "Centor:";
- string mcIsaacLabel = t?.T("McIsaacLabel") ?? "McIsaac:";
- _centorScore.Text = $"{centorLabel} {centor}";
- _mcIsaacScore.Text = $"{mcIsaacLabel} {mcIsaac}";
-
- // Provide brief advice based on McIsaac score (educational)
- string advice = mcIsaac switch
- {
- <= 1 => t?.T("CentorAdvice_0_1") ?? "Low risk: likely viral. No antibiotics. Consider symptomatic care.",
- 2 => t?.T("CentorAdvice_2") ?? "Intermediate risk: consider rapid strep test (RADT).",
- 3 => t?.T("CentorAdvice_3") ?? "Higher risk: RADT and/or consider empiric antibiotics as per local guidance.",
- _ => t?.T("CentorAdvice_4_5") ?? "High risk: consider testing and/or empiric antibiotics per guidelines."
- };
- _centorAdvice.Text = advice;
- }
-
- private void UpdateTriageBanner()
- {
- var selected = new HashSet(_checkedSymptoms, StringComparer.OrdinalIgnoreCase);
- // PERC context: flag PERC positive combined with chest pain or SOB to escalate
- bool chestOrSob = selected.Contains("Chest Pain") || selected.Contains("Shortness of Breath");
- bool percPositive = false;
- try
- {
- // Determine PERC result from current UI state
- bool ageOk = _numAge.Value < 50;
- bool hrOk = _numHR.Value < 100;
- bool spo2Ok = _numSpO2.Value >= 95;
- bool hemoptysisOk = !_percHemoptysis.Checked;
- bool estrogenOk = !_percEstrogen.Checked;
- bool priorOk = !_percPriorDvtPe.Checked;
- bool unilatOk = !_percUnilateralLeg.Checked;
- bool surgeryOk = !_percRecentSurgery.Checked;
- bool percNeg = ageOk && hrOk && spo2Ok && hemoptysisOk && estrogenOk && priorOk && unilatOk && surgeryOk;
- percPositive = !percNeg;
- }
- catch { }
- var keys = SymptomCheckerApp.Services.TriageService.EvaluateV2(
- selected,
- tempC: (double?)_numTempC.Value,
- heartRate: (int?)_numHR.Value,
- respRate: (int?)_numRR.Value,
- systolicBP: (int?)_numSBP.Value,
- diastolicBP: (int?)_numDBP.Value,
- spO2: (int?)_numSpO2.Value,
- percPositiveWithChestOrSob: chestOrSob && percPositive
- );
- if (keys.Count == 0)
- {
- _triageBanner.Visible = false;
- return;
- }
- var t = _translationService;
- var header = t?.T("RedFlagsHeader") ?? "Possible red flags:";
- var messages = new List();
- foreach (var k in keys)
- {
- messages.Add(t?.T(k) ?? k);
- }
- var notice = t?.T("SeekCareDisclaimer") ?? "If these apply, consider seeking urgent medical attention. This tool is educational, not medical advice.";
- bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- if (rtl)
- {
- // For RTL, place bullet at the end for more natural reading
- var lines = messages.Select(m => m + " •");
- var joined = string.Join(Environment.NewLine, lines);
- _triageBanner.Text = header + Environment.NewLine + joined + Environment.NewLine + notice;
- }
- else
- {
- var bullet = string.Join(Environment.NewLine + " • ", messages);
- _triageBanner.Text = header + Environment.NewLine + " • " + bullet + Environment.NewLine + notice;
- }
- _triageBanner.Visible = true;
- }
-
- private void ApplyRtl(Control root, bool rtl)
- {
- // Apply RTL at the form level only; most controls inherit safely from the form
- if (root is Form f)
- {
- try { f.RightToLeft = rtl ? RightToLeft.Yes : RightToLeft.No; } catch { }
- try { f.RightToLeftLayout = rtl; } catch { }
- }
- }
-
- private void ApplyTheme()
- {
- bool dark = _darkModeToggle.Checked;
- Color back = dark ? Color.FromArgb(32, 32, 32) : SystemColors.Window;
- Color fore = dark ? Color.Gainsboro : SystemColors.WindowText;
- Color panel = dark ? Color.FromArgb(24, 24, 24) : SystemColors.Control;
-
- this.BackColor = panel;
- foreach (Control c in this.Controls)
- {
- ApplyThemeToControl(c, back, fore, panel, dark);
- }
- _resultsList.Invalidate();
- }
-
- private void ApplyThemeToControl(Control c, Color back, Color fore, Color panel, bool dark)
- {
- switch (c)
- {
- case SplitContainer sc:
- sc.BackColor = panel;
- ApplyThemeToControl(sc.Panel1, back, fore, panel, dark);
- ApplyThemeToControl(sc.Panel2, back, fore, panel, dark);
- break;
- case TableLayoutPanel tl:
- tl.BackColor = panel;
- foreach (Control child in tl.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
- break;
- case FlowLayoutPanel fl:
- fl.BackColor = panel;
- foreach (Control child in fl.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
- break;
- case CheckedListBox clb:
- clb.BackColor = back; clb.ForeColor = fore;
- break;
- case ListBox lb:
- lb.BackColor = back; lb.ForeColor = fore;
- break;
- case TextBox tb:
- tb.BackColor = back; tb.ForeColor = fore;
- break;
- case ComboBox cb:
- cb.BackColor = back; cb.ForeColor = fore;
- break;
- case Label lbl:
- lbl.BackColor = panel; lbl.ForeColor = fore;
- break;
- case GroupBox gb:
- gb.BackColor = panel; gb.ForeColor = fore;
- foreach (Control child in gb.Controls) ApplyThemeToControl(child, back, fore, panel, dark);
- break;
- case Button btn:
- if (btn == _checkButton)
- {
- // Accent color for the execute button
- btn.BackColor = dark ? Color.FromArgb(40, 167, 69) : Color.MediumSeaGreen;
- btn.ForeColor = Color.White;
- try { btn.FlatAppearance.BorderSize = 1; btn.FlatAppearance.BorderColor = dark ? Color.FromArgb(30, 120, 50) : Color.SeaGreen; } catch { }
- }
- else
- {
- btn.BackColor = dark ? Color.FromArgb(60, 60, 60) : SystemColors.Control;
- btn.ForeColor = fore;
- }
- break;
- case CheckBox chk:
- chk.BackColor = panel; chk.ForeColor = fore;
- break;
- case NumericUpDown nud:
- nud.BackColor = back; nud.ForeColor = fore;
- break;
- case Control generic:
- generic.BackColor = panel; generic.ForeColor = fore;
- break;
- }
- // Triage banner specific colors
- if (c == _triageBanner)
- {
- _triageBanner.BackColor = dark ? Color.FromArgb(64, 48, 0) : Color.FromArgb(255, 245, 230);
- _triageBanner.ForeColor = dark ? Color.Khaki : Color.FromArgb(120, 60, 0);
- }
- }
-
private void CheckButton_Click(object? sender, EventArgs e)
{
if (_service == null) return;
@@ -1426,854 +1340,11 @@ IEnumerable GetCats(string conditionName)
RebuildResultsListItems();
UpdateTriageBanner();
try { _lblPerf.Text = $"{sw.ElapsedMilliseconds} ms"; } catch { }
- }
-
- private void RebuildResultsListItems()
- {
- _resultsList.Items.Clear();
- _resultIndexMap.Clear();
- var t = _translationService;
- if (_lastResults == null || _lastResults.Count == 0)
- {
- _resultsList.Items.Add(t?.T("NoMatches") ?? "No matching conditions found based on the current selection.");
- _resultIndexMap.Add(-1);
- return;
- }
- // If categories service is available, group results by primary category (max overlap)
- var categories = _categoriesService?.GetAllCategories()?.ToList() ?? new List();
- if (categories.Count == 0)
+ // Auto-run AI diagnosis if enabled and Ollama is available
+ if (_autoAiCheck.Checked && _ollamaService?.IsAvailable == true && matches.Count > 0)
{
- // Fallback: flat list
- for (int i = 0; i < _lastResults.Count; i++)
- {
- var m = _lastResults[i];
- string name = t?.Condition(m.Name) ?? m.Name;
- string scoreLabel = t?.T("Score") ?? "Score:";
- string matchesLabel = t?.T("Matches") ?? "matches:";
- _resultsList.Items.Add($"{name} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})");
- _resultIndexMap.Add(i);
- }
- _resultsList.Invalidate();
- return;
- }
-
- // Use cached sets
- var catSets = _categorySetsCache;
-
- // Group results
- var grouped = new Dictionary>(StringComparer.OrdinalIgnoreCase);
- for (int i = 0; i < _lastResults.Count; i++)
- {
- var m = _lastResults[i];
- string displayName = t?.Condition(m.Name) ?? m.Name;
- string scoreLabel = t?.T("Score") ?? "Score:";
- string matchesLabel = t?.T("Matches") ?? "matches:";
- string line = $"{displayName} — {scoreLabel} {m.Score:F2} ({matchesLabel} {m.MatchCount})";
-
- // Determine best matching category
- string bestCat = "Other";
- if (_service != null && _service.TryGetCondition(m.Name, out var c) && c != null)
- {
- int best = -1;
- foreach (var kvp in catSets)
- {
- int overlap = 0;
- foreach (var s in c.Symptoms)
- {
- if (kvp.Value.Contains(s)) overlap++;
- }
- if (overlap > best)
- {
- best = overlap; bestCat = kvp.Key;
- }
- }
- }
- var dispCat = t?.Category(bestCat) ?? bestCat;
- if (!grouped.TryGetValue(dispCat, out var list))
- {
- list = new List<(int, string, double)>();
- grouped[dispCat] = list;
- }
- list.Add((i, line, m.Score));
- }
-
- // Stable group order by localized category name
- foreach (var g in grouped.OrderBy(k => k.Key, StringComparer.CurrentCulture))
- {
- _resultsList.Items.Add(new GroupHeader(g.Key, g.Key));
- _resultIndexMap.Add(-1);
- foreach (var item in g.Value)
- {
- _resultsList.Items.Add(item.line);
- _resultIndexMap.Add(item.resultIndex);
- }
- }
- _resultsList.Invalidate();
- }
-
- private void ResultsList_DrawItem(object? sender, DrawItemEventArgs e)
- {
- e.DrawBackground();
- if (e.Index < 0 || e.Index >= _resultsList.Items.Count)
- {
- return;
- }
-
- string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty;
-
- // Render group headers differently
- if (_resultsList.Items[e.Index] is GroupHeader)
- {
- var boldFont = new Font(e.Font ?? SystemFonts.DefaultFont, FontStyle.Bold);
- bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
- if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
- using (var back = new SolidBrush(Color.FromArgb(245, 245, 245)))
- {
- e.Graphics.FillRectangle(back, e.Bounds);
- }
- var rectH = e.Bounds; rectH.Inflate(-6, -2);
- TextRenderer.DrawText(e.Graphics, text, boldFont, rectH, Color.DimGray, flagsH);
- e.DrawFocusRectangle();
- return;
- }
-
- bool isTop = false;
- // Only highlight if we have actual results and the item corresponds to a scored match
- if (_lastResults != null && _lastResults.Count > 0)
- {
- int resultIdx = (e.Index >= 0 && e.Index < _resultIndexMap.Count) ? _resultIndexMap[e.Index] : -1;
- if (resultIdx >= 0 && resultIdx < _lastResults.Count)
- {
- double max = _lastResults[0].Score;
- double sc = _lastResults[resultIdx].Score;
- isTop = Math.Abs(sc - max) < 1e-9 && max > 0;
- }
- }
-
- Color backColor = isTop ? Color.FromArgb(230, 255, 230) : e.BackColor; // light green for top
- Color foreColor = isTop ? Color.DarkGreen : e.ForeColor;
-
- using (var backBrush = new SolidBrush(backColor))
- using (var foreBrush = new SolidBrush(foreColor))
- {
- e.Graphics.FillRectangle(backBrush, e.Bounds);
- var font = e.Font ?? SystemFonts.DefaultFont;
- bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
- if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
- var rect = e.Bounds;
- rect.Inflate(-4, -2);
- TextRenderer.DrawText(e.Graphics, text, font, rect, foreColor, flags);
- }
-
- e.DrawFocusRectangle();
- }
-
- private void ResultsList_MeasureItem(object? sender, MeasureItemEventArgs e)
- {
- if (e.Index < 0 || e.Index >= _resultsList.Items.Count)
- {
- e.ItemHeight = 22;
- return;
- }
- string text = _resultsList.Items[e.Index]?.ToString() ?? string.Empty;
- if (_resultsList.Items[e.Index] is GroupHeader)
- {
- var boldFont = new Font(_resultsList.Font ?? SystemFonts.DefaultFont, FontStyle.Bold);
- bool rtlH = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- var flagsH = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
- if (rtlH) flagsH |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
- var widthH = Math.Max(10, _resultsList.ClientSize.Width - 12);
- var sizeH = TextRenderer.MeasureText(text, boldFont, new Size(widthH, int.MaxValue), flagsH);
- e.ItemHeight = Math.Max(22, sizeH.Height + 6);
- e.ItemWidth = widthH;
- return;
- }
- var font = _resultsList.Font ?? SystemFonts.DefaultFont;
- bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- var flags = TextFormatFlags.NoPrefix | TextFormatFlags.TextBoxControl | TextFormatFlags.GlyphOverhangPadding | TextFormatFlags.WordBreak;
- if (rtl) flags |= TextFormatFlags.RightToLeft | TextFormatFlags.Right;
- var width = Math.Max(10, _resultsList.ClientSize.Width - 8);
- var size = TextRenderer.MeasureText(text, font, new Size(width, int.MaxValue), flags);
- int min = 22;
- e.ItemHeight = Math.Max(min, size.Height + 4);
- e.ItemWidth = width;
- }
-
- private void ResultsList_DoubleClick(object? sender, EventArgs e)
- {
- if (_service == null) return;
- int idx = _resultsList.SelectedIndex;
- if (idx < 0) return;
- int resultIdx = (idx >= 0 && idx < _resultIndexMap.Count) ? _resultIndexMap[idx] : -1;
- if (resultIdx == -1) return; // header or unmapped
- if (resultIdx < 0 || resultIdx >= _lastResults.Count) return;
-
- var match = _lastResults[resultIdx];
- if (!_service.TryGetCondition(match.Name, out var condition) || condition == null)
- {
- MessageBox.Show(this, $"No details found for '{match.Name}'.", "Details", MessageBoxButtons.OK, MessageBoxIcon.Information);
- return;
- }
-
- var t = _translationService;
- var sb = new System.Text.StringBuilder();
- BuildDetailsText(sb, t, match, condition);
-
- ShowDetailsDialog(t?.T("DetailsTitle") ?? "Condition Details", sb.ToString());
- }
-
- private void BuildDetailsText(System.Text.StringBuilder sb, TranslationService? t, ConditionMatch match, SymptomCheckerApp.Models.Condition condition)
- {
- string name = t?.Condition(match.Name) ?? match.Name;
- string scoreLabel = t?.T("Score") ?? "Score:";
- string matchedLabel = t?.T("MatchedSymptoms") ?? "Matched symptoms:";
- string symptomsLabel = t?.T("SymptomsLabel") ?? "Symptoms:";
- sb.AppendLine(name);
- sb.AppendLine($"{scoreLabel} {match.Score:F3}");
- sb.AppendLine($"{matchedLabel} {match.MatchCount}");
- sb.AppendLine();
- sb.AppendLine(symptomsLabel);
- foreach (var s in condition.Symptoms)
- {
- sb.AppendLine($" • {(t?.Symptom(s) ?? s)}");
- }
-
- // Explainability (simple)
- var model = (DetectionModel)_modelSelector.SelectedItem!;
- sb.AppendLine();
- sb.AppendLine(t?.T("ExplainabilityHeader") ?? "How this score was computed:");
- if (model == DetectionModel.Jaccard || model == DetectionModel.Cosine)
- {
- // Overlap based models
- sb.AppendLine($" • {(t?.T("Explain_MatchedOverlap") ?? "Matched overlap")}: {match.MatchCount}");
- sb.AppendLine($" • {(t?.T("Explain_Similarity") ?? "Similarity")}: {match.Score:F3}");
- }
- else if (model == DetectionModel.NaiveBayes)
- {
- sb.AppendLine($" • {(t?.T("Explain_Prob") ?? "Estimated probability")}: {match.Score:F3}");
- }
-
- // Optional treatment info (educational only). Prefer localized fields when available.
- List? locTreat = null;
- List? locMeds = null;
- string? locAdvice = null;
- var lang = _translationService?.CurrentLanguage?.ToLowerInvariant();
- if (lang == "fr")
- {
- locTreat = condition.Treatments_Fr ?? condition.Treatments;
- locMeds = condition.Medications_Fr ?? condition.Medications;
- locAdvice = condition.CareAdvice_Fr ?? condition.CareAdvice;
- }
- else if (lang == "ar")
- {
- locTreat = condition.Treatments_Ar ?? condition.Treatments;
- locMeds = condition.Medications_Ar ?? condition.Medications;
- locAdvice = condition.CareAdvice_Ar ?? condition.CareAdvice;
- }
- else
- {
- locTreat = condition.Treatments;
- locMeds = condition.Medications;
- locAdvice = condition.CareAdvice;
- }
-
- if (locTreat != null && locTreat.Count > 0)
- {
- sb.AppendLine();
- sb.AppendLine(t?.TDetails("Treatments") ?? "Possible treatments (educational):");
- foreach (var tr in locTreat)
- {
- sb.AppendLine($" • {tr}");
- }
- }
- if (locMeds != null && locMeds.Count > 0)
- {
- sb.AppendLine();
- sb.AppendLine(t?.TDetails("Medications") ?? "Over‑the‑counter examples (educational):");
- foreach (var med in locMeds)
- {
- sb.AppendLine($" • {med}");
- }
- }
- if (!string.IsNullOrWhiteSpace(locAdvice))
- {
- sb.AppendLine();
- sb.AppendLine(t?.TDetails("CareAdvice") ?? "Self‑care advice (educational):");
- sb.AppendLine($" • {locAdvice}");
- }
-
- }
-
- private void CopySelectedDetailsToClipboard()
- {
- if (_service == null) return;
- int idx = _resultsList.SelectedIndex;
- if (idx < 0 || idx >= _lastResults.Count) return;
- var match = _lastResults[idx];
- if (!_service.TryGetCondition(match.Name, out var condition) || condition == null) return;
- var t = _translationService;
- var sb = new System.Text.StringBuilder();
- BuildDetailsText(sb, t, match, condition);
- try { Clipboard.SetText(sb.ToString()); } catch { }
- }
-
- private void ShowDetailsDialog(string title, string text)
- {
- bool rtl = string.Equals(_translationService?.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- using var dlg = new Form
- {
- Text = title,
- StartPosition = FormStartPosition.CenterParent,
- Width = 700,
- Height = 550
- };
- ApplyRtl(dlg, rtl);
- if (rtl)
- {
- text = TransformBulletsForRtl(text);
- }
- var tb = new TextBox
- {
- Multiline = true,
- ReadOnly = true,
- Dock = DockStyle.Fill,
- ScrollBars = ScrollBars.Vertical,
- WordWrap = true,
- BorderStyle = BorderStyle.FixedSingle,
- Text = text
- };
- if (rtl)
- {
- try { tb.RightToLeft = RightToLeft.Yes; } catch { }
- try { tb.TextAlign = HorizontalAlignment.Right; } catch { }
- }
- var btnPanel = new FlowLayoutPanel { Dock = DockStyle.Bottom, AutoSize = true, FlowDirection = FlowDirection.RightToLeft, Padding = new Padding(6) };
- var btnClose = new Button { Text = _translationService?.T("Close") ?? "Close", AutoSize = true };
- btnClose.Click += (s, e) => dlg.Close();
- var btnCopy = new Button { Text = _translationService?.T("Copy") ?? "Copy", AutoSize = true };
- btnCopy.Click += (s, e) => { try { Clipboard.SetText(text); } catch { } };
- btnPanel.Controls.Add(btnClose);
- btnPanel.Controls.Add(btnCopy);
- dlg.Controls.Add(tb);
- dlg.Controls.Add(btnPanel);
- dlg.ShowDialog(this);
- }
-
- private string TransformBulletsForRtl(string input)
- {
- if (string.IsNullOrEmpty(input)) return input;
- var lines = input.Split(new[] { "\r\n", "\n", "\r" }, StringSplitOptions.None);
- for (int i = 0; i < lines.Length; i++)
- {
- var l = lines[i];
- var trimmed = l.TrimStart();
- if (trimmed.StartsWith("• ") || trimmed.StartsWith("•\t") || trimmed.StartsWith("•"))
- {
- // Remove leading bullet and spaces, then append bullet at end
- int idx = l.IndexOf('•');
- if (idx >= 0)
- {
- var after = l.Substring(idx + 1).TrimStart();
- lines[i] = after + " •";
- }
- }
- }
- return string.Join(Environment.NewLine, lines);
- }
-
- private void ShowMissingTranslationsDialog()
- {
- if (_translationService == null)
- {
- MessageBox.Show(this, "Translation service not loaded.", "Translations", MessageBoxButtons.OK, MessageBoxIcon.Information);
- return;
- }
- var missing = _translationService.MissingKeys?.OrderBy(x => x).ToList() ?? new List();
- if (missing.Count == 0)
- {
- MessageBox.Show(this, _translationService.T("NoMissingTranslations") ?? "No missing translations detected.", _translationService.T("MissingTranslationsTitle") ?? "Missing Translations", MessageBoxButtons.OK, MessageBoxIcon.Information);
- return;
- }
- bool rtl = string.Equals(_translationService.CurrentLanguage, "ar", StringComparison.OrdinalIgnoreCase);
- using var dlg = new Form
- {
- Text = _translationService.T("MissingTranslationsTitle") ?? "Missing Translations",
- StartPosition = FormStartPosition.CenterParent,
- Width = 700,
- Height = 500
- };
- ApplyRtl(dlg, rtl);
- var list = new ListBox { Dock = DockStyle.Fill }; if (rtl) try { list.RightToLeft = RightToLeft.Yes; } catch { }
- list.Items.AddRange(missing.Cast