diff --git a/.gitignore b/.gitignore index 8de3b6b..07f1323 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ mono_crash.* [Dd]ebugPublic/ [Rr]elease/ [Rr]eleases/ +# the release banners are app assets, not the "Releases" build output the rule above is aimed at +!FluentMath/Assets/Releases/ x64/ x86/ [Ww][Ii][Nn]32/ diff --git a/AGENTS.md b/AGENTS.md index 016eeca..3779512 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,12 +67,13 @@ double hyphen inside a comment is an XML parse error, so anomaly tags are writte ```text FluentMath/ -├── Assets/ the app icon and the package logos +├── Assets/ the app icon, the package logos, and a banner per release under Releases/ ├── Controls/ the formula display: CalculatorDisplay, MathPanel, XamlTextMeasurer; -│ the converter layout every converter page shares: ConverterPanel +│ the converter layout every converter page shares: ConverterPanel; +│ the release notes body: MarkdownRenderer ├── Distribution/ the channel the running build came from, installer, portable or store, and the │ update check for each: AppDistribution, UpdateService, UpdateInstaller, -│ StoreUpdateSource +│ StoreUpdateSource; the release history: ReleaseCatalog ├── Engines/ the input model and the evaluator, both UI-free: MathInputManager, MathEvaluator ├── Models/ the token model, the exact values, the calculator setup and the currency logic: │ MathToken, NavigationMetadata, EvaluationMetadata, ExactValue, ResultFormatter, @@ -90,7 +91,8 @@ FluentMath/ ├── Properties/ PublishProfiles, launchSettings ├── ViewModels/ CalculatorViewModel, ConverterViewModel, RelayCommand └── Views/ StandardPage, ScientificPage, CurrencyPage, VolumePage, LengthPage, SettingsPage, - UpdateDialog, PadEntrance, ICompactPage, SmallKeyLabels + UpdateDialog, ReleaseNotesDialog, ReleaseNotesPage, PadEntrance, ICompactPage, + SmallKeyLabels FluentMath.Tests/ the engine tests, plain net8.0, no reference to the app ``` @@ -100,8 +102,11 @@ bar, and sizes the window through `WinUIEx.WindowManager`. The navigation groups headers, the calculators (Standard, Scientific) and the converters (Currency, Volume, Length). Both calculators share `CalculatorViewModel` and `CalculatorDisplay`, every converter shares `ConverterViewModel` and `ConverterPanel`, and every page but the settings is cached, so it keeps -its content across a navigation. `MainWindow` also owns compact mode, a small window on top of every -other one that holds the page it was asked from; each page brings its own sizes through `ICompactPage`. +its content across a navigation. "What's New" above the settings opens the release history in a dialog +and never selects. `MainWindow` also owns compact mode, a small window on top of every other one that +holds the page it was asked from; each page brings its own sizes through `ICompactPage`. The title bar +is a plain `Grid` rather than the WinUI `TitleBar`, which hides its title once it holds the update pill +in a narrow window. `Setup/` holds the Inno Setup installer scripts and the `portable.txt` marker of the portable zip, `Calculator/` the retired WPF version 1, and `.github/` the workflows, the issue and pull request @@ -244,10 +249,13 @@ repository setting, not in the build. - **The store build only names its update from GitHub.** Whether there is one is the Store's answer; the GitHub release only gives it a version. Publish the GitHub release once the Store version is live, or the store build shows an unnamed update until then. -- **Two `.gitignore` negations stay.** The stock template silently excludes files the build needs: the +- **Three `.gitignore` negations stay.** The stock template silently excludes files the build needs: the macOS `Icon` rule swallowed `FluentMath/Assets/Icon/`, so a fresh checkout failed with `CS7064` - and the error named the icon rather than anything about git, and `*.pubxml` swallowed all three publish - profiles. + and the error named the icon rather than anything about git, `*.pubxml` swallowed all three publish + profiles, and `[Rr]eleases/` would swallow the release banners in `FluentMath/Assets/Releases/`. +- **Every minor or major release ships a banner.** `FluentMath/Assets/Releases/v--.png`, the + version label with dashes for dots, added before the tag. The release history shows it as the header + of that release; without the file the release simply has none. - **The CodeQL default setup stays switched off in the repository settings.** It collides with the advanced setup in `codeql.yml`, which builds manually because CodeQL autobuild cannot build a WinUI 3 project. diff --git a/FluentMath/App.xaml b/FluentMath/App.xaml index f97cab2..f1ce21d 100644 --- a/FluentMath/App.xaml +++ b/FluentMath/App.xaml @@ -425,6 +425,17 @@ + + + + diff --git a/FluentMath/Assets/Releases/v2-0-0.png b/FluentMath/Assets/Releases/v2-0-0.png new file mode 100644 index 0000000..2911fc1 Binary files /dev/null and b/FluentMath/Assets/Releases/v2-0-0.png differ diff --git a/FluentMath/Assets/Releases/v2-1-0.png b/FluentMath/Assets/Releases/v2-1-0.png new file mode 100644 index 0000000..8b7617c Binary files /dev/null and b/FluentMath/Assets/Releases/v2-1-0.png differ diff --git a/FluentMath/Assets/Releases/v2-2-0.png b/FluentMath/Assets/Releases/v2-2-0.png new file mode 100644 index 0000000..775fa40 Binary files /dev/null and b/FluentMath/Assets/Releases/v2-2-0.png differ diff --git a/FluentMath/Assets/Releases/v2-3-0.png b/FluentMath/Assets/Releases/v2-3-0.png new file mode 100644 index 0000000..135ce60 Binary files /dev/null and b/FluentMath/Assets/Releases/v2-3-0.png differ diff --git a/FluentMath/Controls/MarkdownRenderer.cs b/FluentMath/Controls/MarkdownRenderer.cs new file mode 100644 index 0000000..32d5344 --- /dev/null +++ b/FluentMath/Controls/MarkdownRenderer.cs @@ -0,0 +1,538 @@ +using Microsoft.UI.Text; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Documents; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.RegularExpressions; +using Windows.UI; +using Windows.UI.Text; + +namespace FluentMath.Controls +{ + // the markdown renderer: + // renders the body of a GitHub release; not a general implementation, a release body is headings, lists, bold, + // inline code, links and callouts (the one library is a 0.1.x preview) + // anything unrecognised falls through as plain text instead of disappearing + // renders into a Panel, since RichTextBlock.Blocks only takes Paragraph and a callout needs a Border for its rule + public static class MarkdownRenderer + { + // === layout constants === + + // --- heading sizes and spacing (below full page sizes, a release dialog is narrow) --- + private const double H1FontSize = 20; + private const double H2FontSize = 17; + private const double H3FontSize = 15; + private const double HeadingTopMargin = 16; // except the very first one + private const double HeadingBottomMargin = 5; + private const double ParagraphBottomMargin = 9; + private const double ListItemBottomMargin = 3; + + // a list packs its items tight, so the paragraph after it brings the gap + private const double ParagraphTopMarginAfterList = 18; + + // running text rhythm; a minimum (see NewTextBlock), so a heading keeps its taller line box + private const double BodyLineHeight = 22; + private const double BulletIndent = 16; // list item inset + private const double BulletHang = -11; // pulls the marker back out of it + private const double InlineImageMaxWidth = 420; + + // --- callout geometry --- + private const double AlertRuleThickness = 3; // the bar down the left + private const double AlertInset = 14; // bar to text + + + // === callout colours === + + // GitHub Primer, the values github.com uses; one light and dark pair per alert kind + private static readonly Dictionary AlertColors = new(StringComparer.OrdinalIgnoreCase) + { + ["NOTE"] = (Rgb(0x09, 0x69, 0xDA), Rgb(0x44, 0x93, 0xF8)), + ["TIP"] = (Rgb(0x1A, 0x7F, 0x37), Rgb(0x3F, 0xB9, 0x50)), + ["IMPORTANT"] = (Rgb(0x82, 0x50, 0xDF), Rgb(0xA3, 0x71, 0xF7)), + ["WARNING"] = (Rgb(0x9A, 0x67, 0x00), Rgb(0xD2, 0x99, 0x22)), + ["CAUTION"] = (Rgb(0xCF, 0x22, 0x2E), Rgb(0xF8, 0x51, 0x49)), + }; + + private static Color Rgb(byte r, byte g, byte b) => Color.FromArgb(0xFF, r, g, b); + + + // === dropped sections === + + // a case insensitive substring of the heading, so an emoji in front does not matter + private static readonly string[] DroppedSections = { "Installation" }; + + + // === patterns === + + private static readonly Regex HeadingPattern = new(@"^(#{1,6})\s+(.*)$", RegexOptions.Compiled); + private static readonly Regex BulletPattern = new(@"^\s*[-*+]\s+(.*)$", RegexOptions.Compiled); + private static readonly Regex NumberedPattern = new(@"^\s*(\d+)[.)]\s+(.*)$", RegexOptions.Compiled); + private static readonly Regex RulePattern = new(@"^\s*([-*_])\1{2,}\s*$", RegexOptions.Compiled); + + private static readonly Regex QuotePattern = new(@"^\s*>\s?(.*)$", RegexOptions.Compiled); + + // GitHub callouts inside a blockquote; otherwise "[!IMPORTANT]" would show as text + private static readonly Regex AlertPattern = new( + @"^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*$", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // one pass over every inline form in precedence order, so "**bold**" is no italic pair and + // an image leaves no stray "!" + // no underscore emphasis, it would mangle identifiers like Some_Name_Here + private static readonly Regex InlinePattern = new( + @"(?!\[(?[^\]]*)\]\((?[^\s)]+)\))" + + @"|(?\[(?[^\]]+)\]\((?[^\s)]+)\))" + + @"|(?\*\*(?.+?)\*\*)" + + @"|(?`(?[^`]+)`)" + + @"|(?https?://[^\s<>""]+)" + + @"|(?\*(?[^*\s][^*]*?)\*)", + RegexOptions.Compiled); + + // markdown and the html GitHub writes for a pasted image; the first one is the header image + private static readonly Regex StandaloneImagePattern = new( + @"!\[[^\]]*\]\((?[^\s)]+)\)" + + @"|]*?\ssrc\s*=\s*[""'](?[^""']+)[""'][^>]*>", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + private static readonly Regex HtmlImagePattern = new(@"]*>", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // a bare row of hashes, which GitHub leaves behind + private static readonly Regex EmptyHeadingPattern = new(@"^\s*#{1,6}\s*$", RegexOptions.Compiled); + + // a release body ends on this line; it survives every section removal + private static readonly Regex ChangelogLinkPattern = new(@"^\s*\*\*Full Changelog\*\*", RegexOptions.Compiled | RegexOptions.IgnoreCase); + + // all ExtractChangelogLink keeps of that line + private static readonly Regex UrlPattern = new(@"https?://[^\s<>""]+", RegexOptions.Compiled); + + + // === public api === + + // lifts the first image out as the header image, so it does not show in the running text too + public static string ExtractLeadingImage(string markdown, out string? imageUrl) + { + imageUrl = null; + if (string.IsNullOrWhiteSpace(markdown)) return markdown; + + var match = StandaloneImagePattern.Match(markdown); + if (!match.Success) return markdown; + + imageUrl = match.Groups["imageUrl"].Value; + string rest = markdown.Remove(match.Index, match.Length); + + // leftover html images would show as raw tags + return HtmlImagePattern.Replace(rest, ""); + } + + // strips sections the dialog has no use for (installation steps), down to the next + // heading or the full changelog line + public static string DropSections(string markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) return markdown; + + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var kept = new List(); + bool dropping = false; + + foreach (string line in lines) + { + var heading = HeadingPattern.Match(line); + + if (heading.Success) + { + string title = heading.Groups[2].Value; + dropping = DroppedSections.Any(name => title.Contains(name, StringComparison.OrdinalIgnoreCase)); + if (dropping) continue; + } + else if (dropping && ChangelogLinkPattern.IsMatch(line)) + { + dropping = false; + } + + if (!dropping) kept.Add(line); + } + + return string.Join("\n", kept); + } + + // lifts the full changelog line out and returns its url; the release page shows it as its own XAML row, with a + // themed label and a link button + public static string ExtractChangelogLink(string markdown, out string? url) + { + url = null; + if (string.IsNullOrWhiteSpace(markdown)) return markdown; + + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var kept = new List(); + + foreach (string line in lines) + { + if (url == null && ChangelogLinkPattern.IsMatch(line)) + { + string found = UrlPattern.Match(line).Value; + + // without a usable address the line stays in the body + if (found.Length > 0) + { + url = found; + continue; + } + } + + kept.Add(line); + } + + return string.Join("\n", kept); + } + + // replaces the target content, so re-rendering is safe + public static void Render(Panel target, string markdown) + { + if (target == null) return; + + target.Children.Clear(); + if (string.IsNullOrWhiteSpace(markdown)) return; + + // ActualTheme, the app sets its theme per element + bool isDark = target.ActualTheme == ElementTheme.Dark; + + var lines = markdown.Replace("\r\n", "\n").Replace('\r', '\n').Split('\n'); + var writer = new BlockWriter(target); + + // consecutive plain lines, so a soft-wrapped paragraph stays one + var pending = new List(); + + void FlushPending() + { + if (pending.Count == 0) return; + + double topMargin = writer.LastBlockWasListItem ? ParagraphTopMarginAfterList : 0; + + var paragraph = new Paragraph { Margin = new Thickness(0, topMargin, 0, ParagraphBottomMargin) }; + AppendInlines(paragraph, string.Join(" ", pending)); + writer.Add(paragraph); + + pending.Clear(); + } + + foreach (string raw in lines) + { + string line = raw.TrimEnd(); + + // a blank line, a rule or an empty heading breaks and closes an open callout + if (string.IsNullOrWhiteSpace(line) || RulePattern.IsMatch(line) || EmptyHeadingPattern.IsMatch(line)) + { + FlushPending(); + writer.EndAlert(); + continue; + } + + var quote = QuotePattern.Match(line); + if (quote.Success) + { + FlushPending(); + + string inner = quote.Groups[1].Value.Trim(); + var alert = AlertPattern.Match(inner); + + if (alert.Success) writer.BeginAlert(alert.Groups[1].Value, isDark); + else if (inner.Length > 0) writer.Add(BuildQuote(inner, writer.IsInAlert)); + + continue; + } + + // any non-quote line ends the callout + writer.EndAlert(); + + var heading = HeadingPattern.Match(line); + if (heading.Success) + { + FlushPending(); + writer.Add(BuildHeading(heading.Groups[1].Value.Length, heading.Groups[2].Value, writer.IsFirstBlock)); + continue; + } + + var numbered = NumberedPattern.Match(line); + if (numbered.Success) + { + FlushPending(); + writer.Add(BuildListItem($"{numbered.Groups[1].Value}.", numbered.Groups[2].Value), isListItem: true); + continue; + } + + var bullet = BulletPattern.Match(line); + if (bullet.Success) + { + FlushPending(); + writer.Add(BuildListItem("•", bullet.Groups[1].Value), isListItem: true); + continue; + } + + pending.Add(line.Trim()); + } + + FlushPending(); + writer.EndAlert(); + } + + + // === block writer === + + // the running RichTextBlock, swapped for the callout one while an alert is open, inside a Border for the rule + private sealed class BlockWriter + { + private readonly Panel _target; + private RichTextBlock? _current; + private bool _inAlert; + private bool _anyBlockWritten; + private bool _lastWasListItem; + + public BlockWriter(Panel target) => _target = target; + + public bool IsInAlert => _inAlert; + + // until the first block lands; keeps a leading heading flush with the top + public bool IsFirstBlock => !_anyBlockWritten; + + // so the paragraph after a list brings its gap + public bool LastBlockWasListItem => _lastWasListItem; + + public void Add(Block block, bool isListItem = false) + { + _current ??= StartTextBlock(); + _current.Blocks.Add(block); + _anyBlockWritten = true; + _lastWasListItem = isListItem; + } + + public void BeginAlert(string kind, bool isDark) + { + if (_inAlert) return; + + _inAlert = true; + + Color color = AlertColors.TryGetValue(kind, out var pair) + ? (isDark ? pair.Dark : pair.Light) + : AlertColors["IMPORTANT"].Light; + + var brush = new SolidColorBrush(color); + var body = NewTextBlock(); + + var label = new TextBlock + { + Text = Title(kind), + FontWeight = FontWeights.SemiBold, + Foreground = brush, + Margin = new Thickness(0, 0, 0, 4) + }; + + var stack = new StackPanel(); + stack.Children.Add(label); + stack.Children.Add(body); + + // the rule is the Border left edge, so it spans the whole callout + _target.Children.Add(new Border + { + BorderBrush = brush, + BorderThickness = new Thickness(AlertRuleThickness, 0, 0, 0), + Padding = new Thickness(AlertInset, 2, 0, 2), + Margin = new Thickness(0, 8, 0, 8), + Child = stack + }); + + // everything until EndAlert lands in the callout + _current = body; + _anyBlockWritten = true; + _lastWasListItem = false; + } + + public void EndAlert() + { + if (!_inAlert) return; + + _inAlert = false; + _current = null; + } + + private RichTextBlock StartTextBlock() + { + var block = NewTextBlock(); + _target.Children.Add(block); + + return block; + } + + // MaxHeight, so the line height is a floor; text loosens up and a heading still gets its room + private static RichTextBlock NewTextBlock() => new RichTextBlock + { + IsTextSelectionEnabled = true, + TextWrapping = TextWrapping.Wrap, + LineHeight = BodyLineHeight, + LineStackingStrategy = LineStackingStrategy.MaxHeight + }; + + private static string Title(string kind) => + kind.Length == 0 ? kind : char.ToUpperInvariant(kind[0]) + kind.Substring(1).ToLowerInvariant(); + } + + + // === block builders === + + private static Paragraph BuildHeading(int level, string text, bool isFirstBlock) + { + double fontSize = level switch + { + 1 => H1FontSize, + 2 => H2FontSize, + _ => H3FontSize + }; + + // a leading heading sits flush with the top + var paragraph = new Paragraph + { + FontSize = fontSize, + FontWeight = FontWeights.SemiBold, + Margin = new Thickness(0, isFirstBlock ? 0 : HeadingTopMargin, 0, HeadingBottomMargin) + }; + + AppendInlines(paragraph, text); + return paragraph; + } + + // a quote inside a callout sits behind the rule already, without inset and dimming + private static Paragraph BuildQuote(string text, bool isInAlert) + { + var paragraph = new Paragraph + { + Margin = new Thickness(isInAlert ? 0 : BulletIndent, 0, 0, ListItemBottomMargin) + }; + + if (!isInAlert) + { + var brush = ThemeBrush("TextFillColorSecondaryBrush"); + if (brush != null) paragraph.Foreground = brush; + } + + AppendInlines(paragraph, text); + return paragraph; + } + + // resolves against the application theme, fixed at process start and blind to a theme switch; only blockquotes + // use it, which is why the changelog row is XAML + private static Brush? ThemeBrush(string key) => + Application.Current.Resources.TryGetValue(key, out object value) ? value as Brush : null; + + // hanging indent, so wrapped lines align under the text, not the bullet + private static Paragraph BuildListItem(string marker, string text) + { + var paragraph = new Paragraph + { + Margin = new Thickness(BulletIndent, 0, 0, ListItemBottomMargin), + TextIndent = BulletHang + }; + + paragraph.Inlines.Add(new Run { Text = $"{marker} " }); + AppendInlines(paragraph, text); + + return paragraph; + } + + + // === inline parsing === + + private static void AppendInlines(Paragraph paragraph, string text) + { + int position = 0; + + foreach (Match match in InlinePattern.Matches(text)) + { + if (match.Index > position) + { + paragraph.Inlines.Add(new Run { Text = text.Substring(position, match.Index - position) }); + } + + paragraph.Inlines.Add(BuildInline(match)); + position = match.Index + match.Length; + } + + if (position < text.Length) + { + paragraph.Inlines.Add(new Run { Text = text.Substring(position) }); + } + } + + private static Inline BuildInline(Match match) + { + if (match.Groups["image"].Success) + { + return BuildImage(match.Groups["imageUrl"].Value); + } + + if (match.Groups["link"].Success) + { + return BuildHyperlink(match.Groups["linkText"].Value, match.Groups["linkUrl"].Value); + } + + if (match.Groups["url"].Success) + { + // a url ending a sentence would swallow the punctuation + string url = match.Groups["url"].Value.TrimEnd('.', ',', ';', ':', ')'); + return BuildHyperlink(url, url); + } + + if (match.Groups["bold"].Success) + { + return new Run { Text = match.Groups["boldText"].Value, FontWeight = FontWeights.SemiBold }; + } + + if (match.Groups["code"].Success) + { + return new Run + { + Text = match.Groups["codeText"].Value, + FontFamily = new FontFamily("Cascadia Mono, Consolas, Courier New") + }; + } + + return new Run { Text = match.Groups["italicText"].Value, FontStyle = FontStyle.Italic }; + } + + // an extra image in the running text; loads from its url, so it stays blank offline (unlike the header image, + // which ReleaseCatalog keeps on disk) + private static Inline BuildImage(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return new Run { Text = "" }; + } + + var image = new Image + { + Source = new BitmapImage(uri), + Stretch = Stretch.Uniform, + MaxWidth = InlineImageMaxWidth, + Margin = new Thickness(0, 4, 0, 4) + }; + + return new InlineUIContainer { Child = image }; + } + + // a malformed url falls back to its plain text instead of costing the whole notes + private static Inline BuildHyperlink(string text, string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + { + return new Run { Text = text }; + } + + var hyperlink = new Hyperlink { NavigateUri = uri }; + hyperlink.Inlines.Add(new Run { Text = text }); + + return hyperlink; + } + } +} diff --git a/FluentMath/Distribution/ReleaseCatalog.cs b/FluentMath/Distribution/ReleaseCatalog.cs new file mode 100644 index 0000000..0ed8698 --- /dev/null +++ b/FluentMath/Distribution/ReleaseCatalog.cs @@ -0,0 +1,205 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using FluentMath.Persistence.Services; + +namespace FluentMath.Distribution +{ + // one published release for the history; (UpdateInfo carries the asset and knows the latest only) + public record ReleaseEntry( + string Version, // three part, no v, e.g. "2.3.0" + string TagName, + string Name, + DateTimeOffset PublishedAt, + string Notes, // raw markdown body + string ReleaseUrl + ); + + + // the release catalog: + // every published minor and major release from 2.0.0, for the release notes dialog; apart from UpdateService, + // which asks "anything newer" per start, this asks "what changed, ever" when the dialog opens + // not gated on the startup check (opening the dialog is asking); cached on disk for offline use + public class ReleaseCatalog + { + // === fields === + + private const string ReleasesUrl = "https://api.github.com/repos/cechout/fluent-math/releases?per_page=100"; + private const string CacheFileName = "releases.json"; + + // what the network can rebuild, apart from the state files; deleting it loses no setting + private const string CacheFolderName = "cache"; + + // older is the WPF build, which has no banner and no notes worth showing here + private static readonly Version MinimumVersion = new Version(2, 0, 0); + + private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { WriteIndented = true }; + + private IReadOnlyList? _releases; + private bool _hasFetchedThisRun; + + + // === singleton instance === + + private static readonly ReleaseCatalog _instance = new ReleaseCatalog(); + public static ReleaseCatalog Instance => _instance; + + private ReleaseCatalog() { } + + + // === public api === + + // fetched or read this session, newest first + public IReadOnlyList Releases => _releases ?? Array.Empty(); + + // what the dialog calls; the disk copy holds until a release is missing, so an open costs none of the 60 hourly + // GitHub requests; null = nothing fetched, keep what is shown + // one fetch per run at most, so a local build numbered above every release does not request on every open + public async Task?> EnsureCurrentAsync() + { + var cached = LoadCached(); + + if (cached.Count > 0 && !IsMissingLatest()) return null; + if (_hasFetchedThisRun) return null; + + _hasFetchedThisRun = true; + + return await RefreshAsync(); + } + + // the disk copy, for before the network answers and without one + public IReadOnlyList LoadCached() + { + if (_releases != null) return _releases; + + try + { + string path = CachePath(); + if (!File.Exists(path)) return Array.Empty(); + + var cached = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (cached != null) _releases = cached; + } + catch (Exception ex) + { + // a broken cache is not worth failing over, a refresh replaces it + Debug.WriteLine($"[ReleaseCatalog] cache read failed: {ex.Message}"); + } + + return Releases; + } + + // null when GitHub is unreachable; the cache stays + public async Task?> RefreshAsync() + { + try + { + string json = await UpdateService.Http.GetStringAsync(ReleasesUrl); + + var parsed = Parse(json); + if (parsed.Count == 0) return null; + + _releases = parsed; + WriteCache(parsed); + + return parsed; + } + catch (Exception ex) + { + Debug.WriteLine($"[ReleaseCatalog] refresh failed: {ex.Message}"); + return null; + } + } + + + // === private helpers === + + // a release the catalog ought to carry is missing: the newest one the update check found (once it ran), or the + // running version (always known, so this works with the check off) + // the catalog lists x.y.0 only, so a patch matches its minor; (or 2.3.1 would look missing forever) + private bool IsMissingLatest() + { + return IsMissing(UpdateService.Instance.LatestRelease?.Version) + || IsMissing(UpdateService.CurrentVersion); + } + + private bool IsMissing(string? version) + { + if (string.IsNullOrWhiteSpace(version)) return false; + if (!Version.TryParse(version, out var parsed)) return false; + + return !Releases.Any(entry => entry.Version == $"{parsed.Major}.{parsed.Minor}.0"); + } + + // skips drafts and prereleases like the updater, anything below 2.0.0, and patches (their minor says it all) + private static List Parse(string json) + { + var entries = new List(); + + using var document = JsonDocument.Parse(json); + if (document.RootElement.ValueKind != JsonValueKind.Array) return entries; + + foreach (var element in document.RootElement.EnumerateArray()) + { + if (Flag(element, "draft") || Flag(element, "prerelease")) continue; + + string tag = Text(element, "tag_name"); + string version = tag.TrimStart('v', 'V'); + if (!Version.TryParse(version, out var parsedVersion)) continue; + if (parsedVersion < MinimumVersion) continue; + if (parsedVersion.Build > 0) continue; + + var published = element.TryGetProperty("published_at", out var publishedElement) + && publishedElement.TryGetDateTimeOffset(out var value) + ? value + : DateTimeOffset.MinValue; + + string name = Text(element, "name"); + if (string.IsNullOrWhiteSpace(name)) name = tag; + + entries.Add(new ReleaseEntry( + $"{parsedVersion.Major}.{parsedVersion.Minor}.{Math.Max(parsedVersion.Build, 0)}", + tag, + name, + published, + Text(element, "body"), + Text(element, "html_url"))); + } + + // the api answers newest first; the dialog depends on it, so it is sorted anyway + return entries.OrderByDescending(e => e.PublishedAt).ToList(); + } + + private static string Text(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() ?? "" + : ""; + + private static bool Flag(JsonElement element, string property) => + element.TryGetProperty(property, out var value) && value.ValueKind == JsonValueKind.True; + + private static void WriteCache(List releases) + { + try + { + Directory.CreateDirectory(CacheFolder()); + File.WriteAllText(CachePath(), JsonSerializer.Serialize(releases, _jsonOptions)); + } + catch (Exception ex) + { + // a read only or full disk costs the offline copy only + Debug.WriteLine($"[ReleaseCatalog] cache write failed: {ex.Message}"); + } + } + + // under the app data folder, so a portable copy keeps it on its drive + private static string CacheFolder() => + Path.Combine(PersistenceService.Instance.RootFolder, CacheFolderName); + + private static string CachePath() => Path.Combine(CacheFolder(), CacheFileName); + } +} diff --git a/FluentMath/FluentMath.csproj b/FluentMath/FluentMath.csproj index 7de1e30..056322b 100644 --- a/FluentMath/FluentMath.csproj +++ b/FluentMath/FluentMath.csproj @@ -64,6 +64,14 @@ PreserveNewest + + + PreserveNewest + PreserveNewest + diff --git a/FluentMath/MainWindow.xaml b/FluentMath/MainWindow.xaml index 2696707..8c56682 100644 --- a/FluentMath/MainWindow.xaml +++ b/FluentMath/MainWindow.xaml @@ -179,6 +179,17 @@ + + + + + + + + diff --git a/FluentMath/MainWindow.xaml.cs b/FluentMath/MainWindow.xaml.cs index 0755084..d65c5a6 100644 --- a/FluentMath/MainWindow.xaml.cs +++ b/FluentMath/MainWindow.xaml.cs @@ -13,6 +13,7 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; +using System.Threading.Tasks; using Windows.Foundation; using Windows.Graphics; using WinUIEx; @@ -123,6 +124,12 @@ public MainWindow() private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvokedEventArgs args) { + if (args.InvokedItemContainer.Tag?.ToString() == "WhatsNew") + { + _ = ShowReleaseNotesAsync(); + return; + } + Type? page = PageForTag(args.InvokedItemContainer.Tag.ToString()); // a second click on the item already shown would navigate the page onto itself @@ -145,6 +152,18 @@ private void NavView_ItemInvoked(NavigationView sender, NavigationViewItemInvoke }; } + // a dialog sits beside the window content, so the theme the app sets there is handed over by hand + private async Task ShowReleaseNotesAsync() + { + var dialog = new ReleaseNotesDialog + { + XamlRoot = Content.XamlRoot, + RequestedTheme = Content is FrameworkElement root ? root.ActualTheme : ElementTheme.Default + }; + + await dialog.ShowAsync(); + } + // no frame slide; (the pad pages bring their own entrance) private void ShowPage(Type page) { diff --git a/FluentMath/Views/ReleaseNotesDialog.xaml b/FluentMath/Views/ReleaseNotesDialog.xaml new file mode 100644 index 0000000..30e6c08 --- /dev/null +++ b/FluentMath/Views/ReleaseNotesDialog.xaml @@ -0,0 +1,119 @@ + + + + + + + + 0 + 0 + + + 600 + 0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FluentMath/Views/ReleaseNotesDialog.xaml.cs b/FluentMath/Views/ReleaseNotesDialog.xaml.cs new file mode 100644 index 0000000..d67ed11 --- /dev/null +++ b/FluentMath/Views/ReleaseNotesDialog.xaml.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using FluentMath.Distribution; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace FluentMath.Views +{ + // the release notes dialog: + // every published version from 2.0.0; XAML unlike the other dialogs, since a NavigationView driving a + // Frame reads far better as markup + // the cached history shows at once and the refresh replaces it, so it works offline too + public sealed partial class ReleaseNotesDialog : ContentDialog + { + // === fields === + + private List _releases = new List(); + + // --- dialog geometry --- + // in px; the window can be far narrower than the dialog, so both sides are capped to a share of it + private const double DialogWidth = 550; + private const double MaxDialogHeight = 740; + private const double MinDialogHeight = 460; + private const double HeightFraction = 0.85; // of the window height + private const double MaxWindowShare = 0.95; // of the window width and height + + + // === constructor === + + public ReleaseNotesDialog() + { + this.InitializeComponent(); + } + + + // === lifecycle === + + private async void Dialog_Loaded(object sender, RoutedEventArgs e) + { + ResizeToWindow(); + + // a ContentDialog cannot be resized by hand, so it follows the window + if (this.XamlRoot != null) this.XamlRoot.Changed += OnXamlRootChanged; + this.Closed += (_, _) => + { + if (this.XamlRoot != null) this.XamlRoot.Changed -= OnXamlRootChanged; + }; + + var catalog = ReleaseCatalog.Instance; + + var cached = catalog.LoadCached(); + if (cached.Count > 0) Populate(cached); + else SetBusy(true); + + // GitHub only when the cache misses a release, see EnsureCurrentAsync + var fresh = await catalog.EnsureCurrentAsync(); + + SetBusy(false); + + if (fresh != null && fresh.Count > 0) + { + // only on a real change, or the selection resets for nothing + if (fresh.Count != _releases.Count) Populate(fresh); + } + else if (_releases.Count == 0) + { + // only a fetch that did not come back gets here + ShowStatus("The release history could not be loaded, and nothing has been saved yet"); + } + } + + private void OnXamlRootChanged(XamlRoot sender, XamlRootChangedEventArgs args) => ResizeToWindow(); + + // fixed width and a height that follows the window, both capped by it + private void ResizeToWindow() + { + var size = this.XamlRoot?.Size ?? default; + if (size.Width <= 0 || size.Height <= 0) return; + + double height = Math.Clamp(size.Height * HeightFraction, MinDialogHeight, MaxDialogHeight); + + RootGrid.Width = Math.Min(DialogWidth, size.Width * MaxWindowShare); + RootGrid.Height = Math.Min(height, size.Height * MaxWindowShare); + } + + + // === navigation === + + private void Populate(IReadOnlyList releases) + { + _releases = releases.ToList(); + + ReleaseNav.MenuItems.Clear(); + foreach (var release in _releases) + { + ReleaseNav.MenuItems.Add(new NavigationViewItem + { + Content = UpdateService.VersionLabel(release.Version), + Tag = release + }); + } + + StatusText.Visibility = Visibility.Collapsed; + ReleaseFrame.Visibility = Visibility.Visible; + + if (ReleaseNav.MenuItems.Count > 0) ReleaseNav.SelectedItem = ReleaseNav.MenuItems[0]; + } + + private void ReleaseNav_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (args.SelectedItem is not NavigationViewItem item || item.Tag is not ReleaseEntry release) return; + + // the recommended transition (the vertical entrance for a left pane), so a release + // change reads like any page change + ReleaseFrame.Navigate(typeof(ReleaseNotesPage), release, args.RecommendedNavigationTransitionInfo); + } + + + // === user interaction === + + private void CloseButton_Click(object sender, RoutedEventArgs e) => this.Hide(); + + + // === private helpers === + + private void SetBusy(bool busy) + { + LoadingRing.IsActive = busy; + LoadingRing.Visibility = busy ? Visibility.Visible : Visibility.Collapsed; + } + + private void ShowStatus(string message) + { + StatusText.Text = message; + StatusText.Visibility = Visibility.Visible; + ReleaseFrame.Visibility = Visibility.Collapsed; + } + } +} diff --git a/FluentMath/Views/ReleaseNotesPage.xaml b/FluentMath/Views/ReleaseNotesPage.xaml new file mode 100644 index 0000000..f82ddbe --- /dev/null +++ b/FluentMath/Views/ReleaseNotesPage.xaml @@ -0,0 +1,88 @@ + + + + + + 16,16,16,28 + + + 12 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/FluentMath/Views/ReleaseNotesPage.xaml.cs b/FluentMath/Views/ReleaseNotesPage.xaml.cs new file mode 100644 index 0000000..986db18 --- /dev/null +++ b/FluentMath/Views/ReleaseNotesPage.xaml.cs @@ -0,0 +1,177 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.UI.Xaml.Navigation; +using System; +using System.Diagnostics; +using System.IO; +using System.Threading.Tasks; +using FluentMath.Controls; +using FluentMath.Distribution; +using Windows.Graphics.Imaging; + +namespace FluentMath.Views +{ + // one release in the notes dialog; a Page, so the NavigationView drives it through a Frame + // with the platform transition + public sealed partial class ReleaseNotesPage : Page + { + // === fields === + + // one banner per release under Assets/Releases, the version with dashes for dots + private static readonly string HeroFolder = Path.Combine(AppContext.BaseDirectory, "Assets", "Releases"); + + // kept for the banner, which renders once the page has a width (see Page_Loaded) + private string _version = ""; + + // the pixel width the banner was last rendered at, and a count so a slower render never lands after a newer one + private int _heroPixelWidth; + private int _heroRender; + + + // === constructor === + + public ReleaseNotesPage() + { + this.InitializeComponent(); + } + + + // === navigation === + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + base.OnNavigatedTo(e); + + if (e.Parameter is not ReleaseEntry release) return; + + DateText.Text = release.PublishedAt == DateTimeOffset.MinValue + ? "" + : release.PublishedAt.ToLocalTime().ToString("dd.MM.yyyy"); + + if (Uri.TryCreate(release.ReleaseUrl, UriKind.Absolute, out var releaseUri)) + { + GitHubLink.NavigateUri = releaseUri; + } + else + { + GitHubLink.Visibility = Visibility.Collapsed; + } + + // the leading image leaves the body; ShowHero takes the shipped asset instead + string body = MarkdownRenderer.ExtractLeadingImage(release.Notes, out _); + body = MarkdownRenderer.DropSections(body); + + // the changelog line too, it has its own row + body = MarkdownRenderer.ExtractChangelogLink(body, out string? changelogUrl); + + if (string.IsNullOrWhiteSpace(body)) + { + NotesHost.Visibility = Visibility.Collapsed; + EmptyText.Visibility = Visibility.Visible; + } + else + { + MarkdownRenderer.Render(NotesHost, body); + } + + ShowChangelog(changelogUrl); + + _version = release.Version; + } + + // the banner renders at the page width, which exists only after layout; a scale change is a new size too + private void Page_Loaded(object sender, RoutedEventArgs e) + { + if (XamlRoot != null) XamlRoot.Changed += XamlRoot_Changed; + _ = RenderHeroAsync(); + } + + private void Page_Unloaded(object sender, RoutedEventArgs e) + { + if (XamlRoot != null) XamlRoot.Changed -= XamlRoot_Changed; + } + + private void Page_SizeChanged(object sender, SizeChangedEventArgs e) => _ = RenderHeroAsync(); + + private void XamlRoot_Changed(XamlRoot sender, XamlRootChangedEventArgs args) => _ = RenderHeroAsync(); + + + // === private helpers === + + // the banner ships with the app, off the network, so it shows offline too; a missing file means no header + // + // scaled in one Fant pass (an average over every source pixel) to the physical width of the page and shown + // 1:1 at that size; a decode width plus a stretch to the real, fractional size would filter twice, the + // second time bilinear, and leave the text in the banner soft + private async Task RenderHeroAsync() + { + if (string.IsNullOrWhiteSpace(_version) || XamlRoot == null || ActualWidth <= 0) return; + + double scale = XamlRoot.RasterizationScale; + int pixelWidth = (int)Math.Ceiling(ActualWidth * scale); // ceiling, so no seam is left at the right edge + if (pixelWidth == _heroPixelWidth) return; + + string path = Path.Combine(HeroFolder, $"{UpdateService.VersionLabel(_version).Replace('.', '-')}.png"); + if (!File.Exists(path)) return; + + _heroPixelWidth = pixelWidth; + int render = ++_heroRender; + + try + { + using var stream = File.OpenRead(path).AsRandomAccessStream(); + BitmapDecoder decoder = await BitmapDecoder.CreateAsync(stream); + + uint width = (uint)pixelWidth; + uint height = (uint)Math.Max(1, Math.Round(width * (double)decoder.PixelHeight / decoder.PixelWidth)); + + var transform = new BitmapTransform + { + ScaledWidth = width, + ScaledHeight = height, + InterpolationMode = BitmapInterpolationMode.Fant + }; + + SoftwareBitmap bitmap = await decoder.GetSoftwareBitmapAsync( + BitmapPixelFormat.Bgra8, BitmapAlphaMode.Premultiplied, transform, + ExifOrientationMode.IgnoreExifOrientation, ColorManagementMode.DoNotColorManage); + + var source = new SoftwareBitmapSource(); + await source.SetBitmapAsync(bitmap); + + if (render != _heroRender) return; + + HeroImage.Source = source; + HeroImage.Width = width / scale; + HeroImage.Height = height / scale; + HeroImage.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + // an unreadable banner costs the header only; the next size change tries again + Debug.WriteLine($"[ReleaseNotesPage] banner render failed: {ex.Message}"); + if (render == _heroRender) _heroPixelWidth = 0; + } + } + + // the label is XAML and follows the theme; the address and button text come from here + private void ShowChangelog(string? url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return; + + ChangelogLink.NavigateUri = uri; + ChangelogLink.Content = CompareLabel(uri); + + ChangelogRow.Visibility = Visibility.Visible; + } + + // ".../compare/v2.2.0...v2.3.0" reads as the range; anything else names the host, never blank + private static string CompareLabel(Uri uri) + { + string last = uri.Segments.Length > 0 ? uri.Segments[^1].Trim('/') : ""; + + return last.Length > 0 ? Uri.UnescapeDataString(last) : uri.Host; + } + } +}