diff --git a/Pinta.Core/Classes/DisposableUtilities.cs b/Pinta.Core/Classes/DisposableUtilities.cs new file mode 100644 index 0000000000..187dc909c1 --- /dev/null +++ b/Pinta.Core/Classes/DisposableUtilities.cs @@ -0,0 +1,21 @@ +using System; +using System.Threading; + +namespace Pinta.Core; + +public static class DisposableUtilities +{ + public static IDisposable FromAction (Action action) + { + return new ActionDisposable (action); + } + + private sealed class ActionDisposable (Action action) : IDisposable + { + private Action? action = action; + public void Dispose () + { + Interlocked.Exchange (ref action, null)?.Invoke (); + } + } +} diff --git a/Pinta.Core/Classes/ReactiveUtilities.cs b/Pinta.Core/Classes/ReactiveUtilities.cs new file mode 100644 index 0000000000..8f87fcf66e --- /dev/null +++ b/Pinta.Core/Classes/ReactiveUtilities.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; + +namespace Pinta.Core; + +public static class ReactiveUtilities +{ + public static void NotifyAll (this LinkedList> listeners, TMessage message) + { + foreach (var listener in listeners) + listener.OnNext (message); + } + + public static IDisposable Subscribe (this LinkedList> listeners, IObserver @new) + { + var newNode = listeners.AddLast (@new); + return DisposableUtilities.FromAction (() => listeners.Remove (newNode)); + } +} diff --git a/Pinta.Core/Enumerations/MetricType.cs b/Pinta.Core/Enumerations/MetricType.cs new file mode 100644 index 0000000000..d249e0dbd5 --- /dev/null +++ b/Pinta.Core/Enumerations/MetricType.cs @@ -0,0 +1,8 @@ +namespace Pinta.Core; + +public enum MetricType +{ + Pixels, + Inches, + Centimeters, +} diff --git a/Pinta.Core/Models/RulerModel.cs b/Pinta.Core/Models/RulerModel.cs new file mode 100644 index 0000000000..2ada26514a --- /dev/null +++ b/Pinta.Core/Models/RulerModel.cs @@ -0,0 +1,17 @@ +namespace Pinta.Core; + +public sealed class RulerModel (Gtk.Orientation orientation) +{ + /// The position of the mark along the ruler. + public double Position { get; set; } = 0; + + /// Metric type used for the ruler. + public MetricType Metric { get; set; } = MetricType.Pixels; + + public NumberRange? SelectionBounds { get; set; } = null; + + public NumberRange RulerRange { get; set; } = default; + + /// Whether the ruler is horizontal or vertical. + public Gtk.Orientation Orientation { get; } = orientation; +} diff --git a/Pinta.Gui.Widgets/Widgets/Canvas/CanvasWindow.cs b/Pinta.Gui.Widgets/Widgets/Canvas/CanvasWindow.cs index 9b593ae77a..08c9161caa 100644 --- a/Pinta.Gui.Widgets/Widgets/Canvas/CanvasWindow.cs +++ b/Pinta.Gui.Widgets/Widgets/Canvas/CanvasWindow.cs @@ -36,8 +36,8 @@ public sealed class CanvasWindow : Gtk.Grid private readonly ChromeManager chrome; private readonly ToolManager tools; - private readonly Ruler horizontal_ruler; - private readonly Ruler vertical_ruler; + private readonly RulerViewModel horizontal_ruler; + private readonly RulerViewModel vertical_ruler; private readonly Gtk.ScrolledWindow scrolled_window; private readonly Gtk.EventControllerMotion motion_controller; private readonly Gtk.GestureDrag drag_controller; @@ -97,13 +97,21 @@ public CanvasWindow ( Child = viewPort, }; - Ruler horizontalRuler = new (Gtk.Orientation.Horizontal) { + RulerModel horizontalRulerModel = new (Gtk.Orientation.Horizontal) { Metric = MetricType.Pixels, + }; + RulerModel verticalRulerModel = new (Gtk.Orientation.Vertical) { + Metric = MetricType.Pixels, + }; + + RulerViewModel horizontalRulerViewModel = new (horizontalRulerModel); + RulerViewModel verticalRulerViewModel = new (verticalRulerModel); + + RulerView horizontalRuler = new (horizontalRulerViewModel) { Visible = false, }; - Ruler verticalRuler = new (Gtk.Orientation.Vertical) { - Metric = MetricType.Pixels, + RulerView verticalRuler = new (verticalRulerViewModel) { Visible = false, }; @@ -139,8 +147,8 @@ public CanvasWindow ( scrolled_window = scrolledWindow; gesture_zoom = gestureZoom; - horizontal_ruler = horizontalRuler; - vertical_ruler = verticalRuler; + horizontal_ruler = horizontalRulerViewModel; + vertical_ruler = verticalRulerViewModel; motion_controller = motionController; drag_controller = dragController; diff --git a/Pinta.Gui.Widgets/Widgets/Ruler.cs b/Pinta.Gui.Widgets/Widgets/RulerView.cs similarity index 80% rename from Pinta.Gui.Widgets/Widgets/Ruler.cs rename to Pinta.Gui.Widgets/Widgets/RulerView.cs index 9907368cd4..43caff442e 100644 --- a/Pinta.Gui.Widgets/Widgets/Ruler.cs +++ b/Pinta.Gui.Widgets/Widgets/RulerView.cs @@ -33,110 +33,38 @@ namespace Pinta.Gui.Widgets; -public enum MetricType -{ - Pixels, - Inches, - Centimeters, -} - /// /// Replacement for Gtk.Ruler, which was removed in GTK3. /// Based on the original GTK2 widget and Inkscape's ruler widget. /// -public sealed class Ruler : Gtk.DrawingArea +public sealed class RulerView : Gtk.DrawingArea, + IObserver, + IObserver { - private double position = 0; - private MetricType metric = MetricType.Pixels; - private Surface? cached_surface = null; private Size? last_known_size = null; - private NumberRange? selection_bounds = null; - public NumberRange? SelectionBounds { - get => selection_bounds; - set { - if (selection_bounds == value) return; - selection_bounds = value; - QueueDraw (); - } - } - - /// - /// Whether the ruler is horizontal or vertical. - /// - public Gtk.Orientation Orientation { get; } - - /// - /// Metric type used for the ruler. - /// - public MetricType Metric { - get => metric; - set { - if (metric == value) return; - metric = value; - QueueFullRedraw (); - } - } - - /// - /// The position of the mark along the ruler. - /// - public double Position { - get => position; - set { - if (position == value) return; - position = value; - QueueFullRedraw (); - } - } + private readonly RulerViewModel view_model; - private NumberRange ruler_range; - public NumberRange RulerRange { - get => ruler_range; - set { - if (ruler_range == value) return; - ruler_range = value; - QueueFullRedraw (); - } - } - - public Ruler (Gtk.Orientation orientation) + public RulerView (RulerViewModel viewModel) { - Orientation = orientation; + view_model = viewModel; SetDrawFunc ((area, context, width, height) => Draw (context, new Size (width, height))); // Determine the size request, based on the font size. int font_size = GetFontSize (GetPangoContext ().GetFontDescription ()!, ScaleFactor); - int size = 2 + font_size * 2; - - int width = 0; - int height = 0; - switch (Orientation) { - case Gtk.Orientation.Horizontal: - height = size; - break; - case Gtk.Orientation.Vertical: - width = size; - break; - } - - WidthRequest = width; - HeightRequest = height; - } - - // Invalidates cache _and_ queues redraw. Like a full refresh - private void QueueFullRedraw () - { - InvalidateCache (); - QueueDraw (); - } + int measure = 2 + font_size * 2; + Size size = viewModel.Orientation switch { + Gtk.Orientation.Horizontal => new Size (Width: 0, Height: measure), + Gtk.Orientation.Vertical => new Size (Width: measure, Height: 0), + _ => throw new UnreachableException (), + }; + WidthRequest = size.Width; + HeightRequest = size.Height; - private void InvalidateCache () - { - cached_surface?.Dispose (); - cached_surface = null; + viewModel.Subscribe ((IObserver) this); + viewModel.Subscribe ((IObserver) this); } private static readonly ImmutableArray pixels_ruler_scale = [1, 2, 5, 10, 25, 50, 100, 250, 500, 1000]; @@ -168,7 +96,7 @@ private RulerDrawSettings CreateSettings (Size preliminarySize) { GetStyleContext ().GetColor (out Gdk.RGBA color); - RectangleD rulerOuterLine = Orientation switch { + RectangleD rulerOuterLine = view_model.Orientation switch { Gtk.Orientation.Vertical => new ( X: preliminarySize.Width - 1, @@ -185,27 +113,27 @@ private RulerDrawSettings CreateSettings (Size preliminarySize) _ => throw new UnreachableException (), }; - Size effectiveSize = Orientation switch { + Size effectiveSize = view_model.Orientation switch { Gtk.Orientation.Vertical => new (preliminarySize.Height, preliminarySize.Width),// Swap so that width is the longer dimension (horizontal). Gtk.Orientation.Horizontal => preliminarySize, _ => throw new UnreachableException (), }; - ImmutableArray rulerScale = Metric switch { + ImmutableArray rulerScale = view_model.Metric switch { MetricType.Pixels => pixels_ruler_scale, MetricType.Inches => inches_ruler_scale, MetricType.Centimeters => centimeters_ruler_scale, _ => throw new UnreachableException (), }; - ImmutableArray subdivide = Metric switch { + ImmutableArray subdivide = view_model.Metric switch { MetricType.Pixels => pixels_subdivide, MetricType.Inches => inches_subdivide, MetricType.Centimeters => centimeters_subdivide, _ => throw new UnreachableException (), }; - double pixels_per_unit = Metric switch { + double pixels_per_unit = view_model.Metric switch { MetricType.Pixels => 1.0, MetricType.Inches => 72, MetricType.Centimeters => 28.35, @@ -215,8 +143,8 @@ private RulerDrawSettings CreateSettings (Size preliminarySize) // Find our scaled range. NumberRange scaledRange = new ( - lower: RulerRange.Lower / pixels_per_unit, - upper: RulerRange.Upper / pixels_per_unit); + lower: view_model.RulerRange.Lower / pixels_per_unit, + upper: view_model.RulerRange.Upper / pixels_per_unit); double maxSize = scaledRange.Upper - scaledRange.Lower; @@ -257,11 +185,11 @@ private RulerDrawSettings CreateSettings (Size preliminarySize) Ticks: new ( lower: (int) Math.Floor (scaledRange.Lower * ticksPerUnit), upper: (int) Math.Ceiling (scaledRange.Upper * ticksPerUnit)), - MarkerPosition: GetPositionOnRuler (Position, effectiveSize.Width), + MarkerPosition: GetPositionOnRuler (view_model.Position, effectiveSize.Width), RulerOuterLine: rulerOuterLine, EffectiveSize: effectiveSize, Color: color.ToCairoColor (), - Orientation: Orientation); + Orientation: view_model.Orientation); } private void Draw (Context cr, Size preliminarySize) @@ -276,11 +204,11 @@ private void Draw (Context cr, Size preliminarySize) cached_surface ??= CreateBaseRuler (settings, preliminarySize); // Draw the selection projection if a selection exists - if (selection_bounds.HasValue) { + if (view_model.SelectionBounds.HasValue) { // Convert selection coordinates to ruler widget coordinates - double p1 = GetPositionOnRuler (selection_bounds.Value.Lower, settings.EffectiveSize.Width); - double p2 = GetPositionOnRuler (selection_bounds.Value.Upper, settings.EffectiveSize.Width); + double p1 = GetPositionOnRuler (view_model.SelectionBounds.Value.Lower, settings.EffectiveSize.Width); + double p2 = GetPositionOnRuler (view_model.SelectionBounds.Value.Upper, settings.EffectiveSize.Width); cr.SetSourceRgba ( // Semi-transparent blue red: 0.21, @@ -288,7 +216,7 @@ private void Draw (Context cr, Size preliminarySize) blue: 0.89, alpha: 0.25); - switch (Orientation) { + switch (view_model.Orientation) { case Gtk.Orientation.Horizontal: cr.Rectangle (p1, 0, p2 - p1, settings.EffectiveSize.Height); break; @@ -384,9 +312,9 @@ private ImageSurface CreateBaseRuler (in RulerDrawSettings settings, Size prelim [MethodImpl (MethodImplOptions.AggressiveInlining)] private double GetPositionOnRuler (double position, double width) { - double range = RulerRange.Upper - RulerRange.Lower; + double range = view_model.RulerRange.Upper - view_model.RulerRange.Lower; double scaledWidth = width / range; - double positionFromLower = position - RulerRange.Lower; + double positionFromLower = position - view_model.RulerRange.Lower; return positionFromLower * scaledWidth; } @@ -398,4 +326,35 @@ private static int GetFontSize (Pango.FontDescription font, int scaleFactor) else return (int) (scaleFactor * fontSize / 72.0); } + + private void InvalidateCache () + { + cached_surface?.Dispose (); + cached_surface = null; + } + + private void QueueFullRedraw () + { + InvalidateCache (); + QueueDraw (); + } + + public void OnNext (RulerChanged value) + { + if (value.Full) + QueueFullRedraw (); + else + QueueDraw (); + } + + public void OnNext (RulerVisibilityChanged value) + { + Visible = value.NewVisibility; + } + + void IObserver.OnCompleted () { } + void IObserver.OnError (Exception error) => throw new NotImplementedException (); + + void IObserver.OnCompleted () { } + void IObserver.OnError (Exception error) => throw new NotImplementedException (); } diff --git a/Pinta.Gui.Widgets/Widgets/RulerViewModel.cs b/Pinta.Gui.Widgets/Widgets/RulerViewModel.cs new file mode 100644 index 0000000000..b7e14b10ed --- /dev/null +++ b/Pinta.Gui.Widgets/Widgets/RulerViewModel.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using Pinta.Core; + +namespace Pinta.Gui.Widgets; + +public readonly record struct RulerChanged (bool Full); +public readonly record struct RulerVisibilityChanged (bool NewVisibility); + +public sealed class RulerViewModel : + IObservable, + IObservable +{ + private readonly LinkedList> change_observers = new (); + private readonly LinkedList> visibility_observers = new (); + + private readonly RulerModel model; + internal RulerViewModel (RulerModel model) + { + this.model = model; + } + + public Gtk.Orientation Orientation + => model.Orientation; + + public double Position { + get => model.Position; + set { + if (model.Position == value) return; + model.Position = value; + OnRulerChanged (full: true); + } + } + + public MetricType Metric { + get => model.Metric; + set { + if (model.Metric == value) return; + model.Metric = value; + OnRulerChanged (full: true); + } + } + + public NumberRange? SelectionBounds { + get => model.SelectionBounds; + set { + if (model.SelectionBounds == value) return; + model.SelectionBounds = value; + OnRulerChanged (full: false); + } + } + + public NumberRange RulerRange { + get => model.RulerRange; + set { + if (model.RulerRange == value) return; + model.RulerRange = value; + OnRulerChanged (full: true); + } + } + + bool visible = false; + public bool Visible { + get => visible; + set { + if (visible == value) return; + visible = value; + OnVisibilityChanged (value); + } + } + + private void OnRulerChanged (bool full) + => change_observers.NotifyAll (new (full)); + + private void OnVisibilityChanged (bool newVisibility) + => visibility_observers.NotifyAll (new (newVisibility)); + + public IDisposable Subscribe (IObserver observer) + => change_observers.Subscribe (observer); + + public IDisposable Subscribe (IObserver observer) + => visibility_observers.Subscribe (observer); +}