diff --git a/SukiUI.Demo/Features/Theming/ThemingView.axaml b/SukiUI.Demo/Features/Theming/ThemingView.axaml index 4545abf94..b18cc8892 100644 --- a/SukiUI.Demo/Features/Theming/ThemingView.axaml +++ b/SukiUI.Demo/Features/Theming/ThemingView.axaml @@ -93,25 +93,39 @@ - + - + + IsChecked="{Binding BackgroundAnimations}" /> - + + + + + + + + diff --git a/SukiUI.Demo/Features/Theming/ThemingViewModel.cs b/SukiUI.Demo/Features/Theming/ThemingViewModel.cs index b54245dc1..1e4bc2655 100644 --- a/SukiUI.Demo/Features/Theming/ThemingViewModel.cs +++ b/SukiUI.Demo/Features/Theming/ThemingViewModel.cs @@ -1,43 +1,52 @@ -using Avalonia.Collections; +using System; +using Avalonia.Collections; using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using Material.Icons; +using SukiUI.Enums; using SukiUI.Models; namespace SukiUI.Demo.Features.Theming; public partial class ThemingViewModel : DemoPageBase { + public Action BackgroundStyleChanged { get; set; } + public Action BackgroundAnimationsChanged { get; set; } + public IAvaloniaReadOnlyList AvailableColors { get; } + public IAvaloniaReadOnlyList AvailableBackgroundStyles { get; } private readonly SukiTheme _theme = SukiTheme.GetInstance(); - [ObservableProperty] private bool _isBackgroundAnimated; [ObservableProperty] private bool _isLightTheme; + [ObservableProperty] private SukiBackgroundStyle _backgroundStyle; + [ObservableProperty] private bool _backgroundAnimations; public ThemingViewModel() : base("Theming", MaterialIconKind.PaletteOutline, -200) { + AvailableBackgroundStyles = new AvaloniaList(Enum.GetValues()); AvailableColors = _theme.ColorThemes; IsLightTheme = _theme.ActiveBaseTheme == ThemeVariant.Light; - IsBackgroundAnimated = _theme.IsBackgroundAnimated; _theme.OnBaseThemeChanged += variant => IsLightTheme = variant == ThemeVariant.Light; _theme.OnColorThemeChanged += theme => { // TODO: Implement a way to make the correct, might need to wrap the thing in a VM, this isn't ideal. }; - _theme.OnBackgroundAnimationChanged += value => - IsBackgroundAnimated = value; } partial void OnIsLightThemeChanged(bool value) => _theme.ChangeBaseTheme(value ? ThemeVariant.Light : ThemeVariant.Dark); - partial void OnIsBackgroundAnimatedChanged(bool value) => - _theme.SetBackgroundAnimationsEnabled(value); [RelayCommand] public void SwitchToColorTheme(SukiColorTheme colorTheme) => _theme.ChangeColorTheme(colorTheme); + + partial void OnBackgroundStyleChanged(SukiBackgroundStyle value) => + BackgroundStyleChanged?.Invoke(value); + + partial void OnBackgroundAnimationsChanged(bool value) => + BackgroundAnimationsChanged?.Invoke(value); } \ No newline at end of file diff --git a/SukiUI.Demo/SukiUIDemoView.axaml b/SukiUI.Demo/SukiUIDemoView.axaml index a5c046b5d..89050f67a 100644 --- a/SukiUI.Demo/SukiUIDemoView.axaml +++ b/SukiUI.Demo/SukiUIDemoView.axaml @@ -13,6 +13,7 @@ d:DesignWidth="800" x:DataType="demo:SukiUIDemoViewModel" BackgroundAnimationEnabled="{Binding AnimationsEnabled}" + BackgroundStyle="{Binding BackgroundStyle}" CanMinimize="{Binding !WindowLocked}" CanMove="{Binding !WindowLocked}" CanResize="{Binding !WindowLocked}" @@ -41,11 +42,6 @@ - - - - - @@ -65,7 +61,7 @@ ToolTip.Tip="Makes the app fullscreen." /> - @@ -77,6 +73,17 @@ + + + + + + + + + diff --git a/SukiUI.Demo/SukiUIDemoView.axaml.cs b/SukiUI.Demo/SukiUIDemoView.axaml.cs index 0cf41e411..f8631871c 100644 --- a/SukiUI.Demo/SukiUIDemoView.axaml.cs +++ b/SukiUI.Demo/SukiUIDemoView.axaml.cs @@ -2,6 +2,7 @@ using Avalonia.Input; using Avalonia.Interactivity; using SukiUI.Controls; +using SukiUI.Enums; using SukiUI.Models; namespace SukiUI.Demo; @@ -13,7 +14,7 @@ public SukiUIDemoView() InitializeComponent(); } - private void MenuItem_OnClick(object? sender, RoutedEventArgs e) + private void ThemeMenuItem_OnClick(object? sender, RoutedEventArgs e) { if (DataContext is not SukiUIDemoViewModel vm) return; if (e.Source is not MenuItem mItem) return; @@ -21,6 +22,14 @@ private void MenuItem_OnClick(object? sender, RoutedEventArgs e) vm.ChangeTheme(cTheme); } + private void BackgroundMenuItem_OnClick(object? sender, RoutedEventArgs e) + { + if (DataContext is not SukiUIDemoViewModel vm) return; + if (e.Source is not MenuItem mItem) return; + if (mItem.DataContext is not SukiBackgroundStyle cStyle) return; + vm.BackgroundStyle = cStyle; + } + private void InputElement_OnPointerPressed(object? sender, PointerPressedEventArgs e) { IsMenuVisible = !IsMenuVisible; diff --git a/SukiUI.Demo/SukiUIDemoViewModel.cs b/SukiUI.Demo/SukiUIDemoViewModel.cs index fa7abebde..7d8ab13c1 100644 --- a/SukiUI.Demo/SukiUIDemoViewModel.cs +++ b/SukiUI.Demo/SukiUIDemoViewModel.cs @@ -1,3 +1,4 @@ +using System; using Avalonia.Collections; using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; @@ -11,6 +12,8 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using SukiUI.Demo.Features.Theming; +using SukiUI.Enums; namespace SukiUI.Demo; @@ -19,18 +22,27 @@ public partial class SukiUIDemoViewModel : ObservableObject public IAvaloniaReadOnlyList DemoPages { get; } public IAvaloniaReadOnlyList Themes { get; } + + public IAvaloniaReadOnlyList BackgroundStyles { get; } [ObservableProperty] private ThemeVariant _baseTheme; - [ObservableProperty] private bool _animationsEnabled; [ObservableProperty] private DemoPageBase? _activePage; [ObservableProperty] private bool _windowLocked; [ObservableProperty] private bool _titleBarVisible = true; + [ObservableProperty] private SukiBackgroundStyle _backgroundStyle; + [ObservableProperty] private bool _animationsEnabled; private readonly SukiTheme _theme; + private readonly ThemingViewModel _theming; public SukiUIDemoViewModel(IEnumerable demoPages, PageNavigationService pageNavigationService) { DemoPages = new AvaloniaList(demoPages.OrderBy(x => x.Index).ThenBy(x => x.DisplayName)); + _theming = (ThemingViewModel)DemoPages.First(x => x is ThemingViewModel); + _theming.BackgroundStyleChanged += style => BackgroundStyle = style; + _theming.BackgroundAnimationsChanged += enabled => AnimationsEnabled = enabled; + + BackgroundStyles = new AvaloniaList(Enum.GetValues()); _theme = SukiTheme.GetInstance(); // Subscribe to the navigation service (when a page navigation is requested) @@ -54,10 +66,6 @@ public SukiUIDemoViewModel(IEnumerable demoPages, PageNavigationSe // Subscribe to the color theme changed events _theme.OnColorThemeChanged += theme => SukiHost.ShowToast("Successfully Changed Color", $"Changed Color To {theme.DisplayName}."); - - // Subscribe to the background animation changed events - _theme.OnBackgroundAnimationChanged += - value => AnimationsEnabled = value; } [RelayCommand] @@ -102,4 +110,10 @@ private void ToggleTitleBar() [RelayCommand] private static void OpenUrl(string url) => UrlUtilities.OpenUrl(url); + + partial void OnBackgroundStyleChanged(SukiBackgroundStyle value) => + _theming.BackgroundStyle = value; + + partial void OnAnimationsEnabledChanged(bool value) => + _theming.BackgroundAnimations = value; } \ No newline at end of file diff --git a/SukiUI/Content/Shaders/cells.sksl b/SukiUI/Content/Shaders/cells.sksl new file mode 100644 index 000000000..3a4454861 --- /dev/null +++ b/SukiUI/Content/Shaders/cells.sksl @@ -0,0 +1,44 @@ +vec3 blendOverlay(vec3 base, vec3 blend) { + return vec3( + base.r < 0.5 ? (2.0 * base.r * blend.r) : (1.0 - 2.0 * (1.0 - base.r) * (1.0 - blend.r)), + base.g < 0.5 ? (2.0 * base.g * blend.g) : (1.0 - 2.0 * (1.0 - base.g) * (1.0 - blend.g)), + base.b < 0.5 ? (2.0 * base.b * blend.b) : (1.0 - 2.0 * (1.0 - base.b) * (1.0 - blend.b)) + ); +} + +vec2 ran(vec2 uv) { + uv *= vec2(dot(uv, vec2(127.1, 311.7)), dot(uv, vec2(227.1, 521.7))); + return 1.0 - fract(tan(cos(uv) * 123.6) * 3533.3) * fract(tan(cos(uv) * 123.6) * 3533.3); +} +vec2 pt(vec2 id) { + return sin(iTime * 0.5 * (ran(id + .5) - 0.5) + ran(id - 20.1) * 8.0) * 0.5; +} + +vec4 main(vec2 fragCoord) +{ + float SIZE = 10.; + vec2 uv = (fragCoord - .5 * iResolution.xy) / iResolution.x; + vec2 off = iTime / vec2(200., 120.); + uv += off; + uv *= SIZE; + + vec2 gv = fract(uv) - .5; + vec2 id = floor(uv); + + float mindist = 1e9; + vec2 vorv = vec2(0); + for (float i = -1.;i <= 1.; i++) { + for (float j = -1.;j <= 1.; j++) { + vec2 offv = vec2(i, j); + float dist = length(gv + pt(id + offv) - offv); + if (dist < mindist) { + mindist = dist; + vorv = (id + pt(id + offv) + offv) / SIZE - off; + } + } + } + + vec3 col = mix(iPrimary, iAccent, clamp(vorv.x * 2.2 + vorv.y, -1., 1.) * 0.5 + 0.5); + vec3 comp = blendOverlay(iBase, col); + return vec4(comp, 1.); +} \ No newline at end of file diff --git a/SukiUI/Content/Shaders/flat.sksl b/SukiUI/Content/Shaders/flat.sksl new file mode 100644 index 000000000..d4adc5135 --- /dev/null +++ b/SukiUI/Content/Shaders/flat.sksl @@ -0,0 +1,3 @@ +vec4 main(vec2 fragCoord) { + return vec4(iBase, 1.0); +} \ No newline at end of file diff --git a/SukiUI/Content/Shaders/gradient.sksl b/SukiUI/Content/Shaders/gradient.sksl new file mode 100644 index 000000000..abcf97774 --- /dev/null +++ b/SukiUI/Content/Shaders/gradient.sksl @@ -0,0 +1,63 @@ +float smoothstep(float a, float b, float x) { + float t = clamp((x - a) / (b - a), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +vec3 blendOverlay(vec3 base, vec3 blend) { + return vec3( + base.r < 0.5 ? (2.0 * base.r * blend.r) : (1.0 - 2.0 * (1.0 - base.r) * (1.0 - blend.r)), + base.g < 0.5 ? (2.0 * base.g * blend.g) : (1.0 - 2.0 * (1.0 - base.g) * (1.0 - blend.g)), + base.b < 0.5 ? (2.0 * base.b * blend.b) : (1.0 - 2.0 * (1.0 - base.b) * (1.0 - blend.b)) + ); +} + +mat2 Rot(float a) { + float s = sin(a); + float c = cos(a); + return mat2(c, -s, s, c); +} + +vec2 hash(vec2 p) { + p = vec2(dot(p, vec2(2127.1, 81.17)), dot(p, vec2(1269.5, 283.37))); + return fract(sin(p) * 43758.5453); +} + +float noise(in vec2 p) { + vec2 i = floor(p); + vec2 f = fract(p); + + vec2 u = f * f * (3.0 - 2.0 * f); + + float n = mix(mix(dot(-1.0 + 2.0 * hash(i + vec2(0.0, 0.0)), f - vec2(0.0, 0.0)), + dot(-1.0 + 2.0 * hash(i + vec2(1.0, 0.0)), f - vec2(1.0, 0.0)), u.x), + mix(dot(-1.0 + 2.0 * hash(i + vec2(0.0, 1.0)), f - vec2(0.0, 1.0)), + dot(-1.0 + 2.0 * hash(i + vec2(1.0, 1.0)), f - vec2(1.0, 1.0)), u.x), u.y); + return 0.5 + 0.5 * n; +} + +vec4 main(vec2 fragCoord) { + vec2 uv = fragCoord / iResolution.xy; + float ratio = iResolution.x / iResolution.y; + + vec2 tuv = uv; + tuv -= .5; + + float degree = noise(vec2(iTime * .1, tuv.x * tuv.y)); + + tuv.y *= 1. / ratio; + tuv *= Rot(radians((degree - .5) * 720. + 180.)); + tuv.y *= ratio; + + float frequency = 10.; + float amplitude = 15.; + float speed = iTime * 0.1; + tuv.x += sin(tuv.y * frequency + speed) / amplitude; + tuv.y += sin(tuv.x * frequency * 1.5 + speed) / (amplitude * .5); + + vec3 layer1 = mix(iAccent, iPrimary * 0.85, smoothstep(-.3, .2, (tuv * Rot(radians(-5.))).x)); + vec3 layer2 = mix(iPrimary, iAccent * 0.65, smoothstep(-.3, .2, (tuv * Rot(radians(-5.))).x)); + vec3 finalComp = mix(layer1, layer2, smoothstep(.5, -.3, tuv.y)); + vec3 col = blendOverlay(iBase, finalComp); + + return vec4(col, 1.0); +} \ No newline at end of file diff --git a/SukiUI/Content/Shaders/waves.sksl b/SukiUI/Content/Shaders/waves.sksl new file mode 100644 index 000000000..ca63a44cb --- /dev/null +++ b/SukiUI/Content/Shaders/waves.sksl @@ -0,0 +1,55 @@ +const int POINTS = 5; // Point rows are determined like N / 10, from bottom to up +const float WAVE_OFFSET = 5000.0; +const float SPEED = 0.05; + +float mod(float x, float y) { + return x - y * floor(x / y); +} + +float smoothstep(float a, float b, float x) { + float t = clamp((x - a) / (b - a), 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); +} + +vec3 blendOverlay(vec3 base, vec3 blend) { + return vec3( + base.r < 0.5 ? (2.0 * base.r * blend.r) : (1.0 - 2.0 * (1.0 - base.r) * (1.0 - blend.r)), + base.g < 0.5 ? (2.0 * base.g * blend.g) : (1.0 - 2.0 * (1.0 - base.g) * (1.0 - blend.g)), + base.b < 0.5 ? (2.0 * base.b * blend.b) : (1.0 - 2.0 * (1.0 - base.b) * (1.0 - blend.b)) + ); +} + +void voronoi(vec2 uv, inout vec3 col) +{ + vec3 voronoi = vec3(0.0); + float time = (iTime + WAVE_OFFSET)*SPEED; + float bestDistance = 999.0; + float lastBestDistance = bestDistance; + for (int i = 0; i < POINTS; i++) + { + float fi = float(i); + vec2 p = vec2(mod(fi, 1.0) * 0.1 + sin(fi), + -0.05 + 0.15 * float(i / 10) + cos(fi + time * cos(uv.x * 0.025))); + float d = distance(uv, p); + if (d < bestDistance) + { + lastBestDistance = bestDistance; + bestDistance = d; + voronoi.x = p.x; + voronoi.yz = vec2(p.x * 0.4 + p.y, p.y) * vec2(0.9, 0.87); + } + } + col *= 0.68 + 0.19 * voronoi; + col += smoothstep(0.99, 1.05, 1.0 - abs(bestDistance - lastBestDistance)) * 0.9; + col += smoothstep(0.95, 1.01, 1.0 - abs(bestDistance - lastBestDistance)) * 0.1 * col; + col += (voronoi) * 0.1 * smoothstep(0.5, 1.0, 1.0 - abs(bestDistance - lastBestDistance)); +} + +vec4 main(vec2 fragCoord ) +{ + vec2 uv = fragCoord/iResolution.xy; + vec3 col = mix(iPrimary, iAccent, uv.x); + voronoi(uv * 4.0 - 1.0, col); + vec3 finalCol = blendOverlay(iBase, col); + return vec4(finalCol,1.0); +} \ No newline at end of file diff --git a/SukiUI/Controls/SukiBackground.cs b/SukiUI/Controls/SukiBackground.cs index 4d2f2b165..303a0caa6 100644 --- a/SukiUI/Controls/SukiBackground.cs +++ b/SukiUI/Controls/SukiBackground.cs @@ -1,77 +1,110 @@ +using System; +using System.Reactive; +using System.Reactive.Linq; using Avalonia; using Avalonia.Controls; +using Avalonia.Interactivity; using Avalonia.Media; -using Avalonia.Media.Imaging; -using Avalonia.Platform; using Avalonia.Threading; +using SukiUI.Enums; using SukiUI.Utilities.Background; -using System; -using System.Timers; - -namespace SukiUI.Controls; -public class SukiBackground : Image, IDisposable +namespace SukiUI.Controls { - private const int ImageWidth = 100; - private const int ImageHeight = 100; - private const float AnimFps = 5; - - private readonly WriteableBitmap _bmp = new(new PixelSize(ImageWidth, ImageHeight), new Vector(96, 96), - PixelFormats.Bgra8888); - - /// - /// Quickly and easily assign a generator either for testing, or in future allow dev-defined generators... - /// - private readonly ISukiBackgroundRenderer _renderer = new FastNoiseBackgroundRenderer(); + public class SukiBackground : Control + { - private static readonly Timer _animationTick = new(1000 / AnimFps) { AutoReset = true }; // 1 fps + public static readonly StyledProperty StyleProperty = + AvaloniaProperty.Register(nameof(Style), + defaultValue: SukiBackgroundStyle.Waves); + + /// + /// Which of the default background styles to use. + /// + public SukiBackgroundStyle Style + { + get => GetValue(StyleProperty); + set => SetValue(StyleProperty, value); + } - public bool AnimationEnabled { get; private set; } = false; + public static readonly StyledProperty ShaderFileProperty = + AvaloniaProperty.Register(nameof(ShaderFile)); - private readonly SukiTheme _theme; + /// + /// Specify a filename of an EMBEDDED RESOURCE file of type `.SkSL` with or without extension and it will be loaded and displayed. + /// This takes priority over the property, which in turns takes priority over . + /// + public string? ShaderFile + { + get => GetValue(ShaderFileProperty); + set => SetValue(ShaderFileProperty, value); + } - public SukiBackground() - { - Source = _bmp; - Stretch = Stretch.UniformToFill; - _animationTick.Elapsed += (_, _) => _renderer.Render(_bmp,Dispatcher.UIThread.Invoke(() => _theme.ActiveBaseTheme)); - _theme = SukiTheme.GetInstance(); - _theme.RegisterBackground(this); - } + public static readonly StyledProperty ShaderCodeProperty = + AvaloniaProperty.Register(nameof(ShaderCode)); - public override void EndInit() - { - base.EndInit(); + /// + /// Specify the shader code to use directly, simpler if you don't want to create an .SkSL file or want to generate the shader effect at runtime in some way. + /// This takes priority over the property, but is second in priority to if it is set. + /// + public string? ShaderCode + { + get => GetValue(ShaderCodeProperty); + set => SetValue(ShaderCodeProperty, value); + } + + public static readonly StyledProperty AnimationEnabledProperty = + AvaloniaProperty.Register(nameof(AnimationEnabled), defaultValue: false); - _theme.OnColorThemeChanged += theme => + public bool AnimationEnabled { - _renderer.UpdateValues(theme, Dispatcher.UIThread.Invoke(() => _theme.ActiveBaseTheme)); - _renderer.Render(_bmp, Dispatcher.UIThread.Invoke(() => _theme.ActiveBaseTheme)); - }; - _theme.OnBaseThemeChanged += baseTheme => + get => GetValue(AnimationEnabledProperty); + set => SetValue(AnimationEnabledProperty, value); + } + + private readonly ShaderBackgroundDraw _draw; + private SukiBackgroundEffect _effect; + private readonly IDisposable _observables; + + public SukiBackground() { - _renderer.UpdateValues(_theme.ActiveColorTheme, baseTheme); - _renderer.Render(_bmp, Dispatcher.UIThread.Invoke(() => _theme.ActiveBaseTheme)); - }; - - _renderer.UpdateValues(_theme.ActiveColorTheme, _theme.ActiveBaseTheme); - _renderer.Render(_bmp,_theme.ActiveBaseTheme); - - if (AnimationEnabled) _animationTick.Start(); - } - - public void SetAnimationEnabled(bool value) - { - if (AnimationEnabled == value) return; - AnimationEnabled = value; - if (!_renderer.SupportsAnimation) return; - _theme.OnBackgroundAnimationChanged?.Invoke(AnimationEnabled); - if (AnimationEnabled) _animationTick.Start(); - else _animationTick.Stop(); - } + IsHitTestVisible = false; + _draw = new ShaderBackgroundDraw(new Rect(0, 0, Bounds.Width, Bounds.Height)); + var bgStyleObs = this.GetObservable(StyleProperty) + .Select(_ => Unit.Default); + var bgShaderFileObs = this.GetObservable(ShaderFileProperty) + .Select(_ => Unit.Default) + .Merge(bgStyleObs); + var bgShaderCodeObs = this.GetObservable(ShaderCodeProperty) + .Select(_ => Unit.Default) + .Merge(bgShaderFileObs) + .Do(_ => HandleBackgroundStyleChanges()) + .ObserveOn(new AvaloniaSynchronizationContext()); + _observables = bgShaderCodeObs.Subscribe(); + } + + public override void Render(DrawingContext context) + { + _draw.Bounds = Bounds; + _draw.Effect = _effect; + _draw.AnimEnabled = AnimationEnabled; + context.Custom(_draw); + Dispatcher.UIThread.InvokeAsync(InvalidateVisual, DispatcherPriority.Background); + } + + private void HandleBackgroundStyleChanges() + { + if (ShaderFile is not null) + _effect = SukiBackgroundEffect.FromEmbeddedResource(ShaderFile); + else if (ShaderCode is not null) + _effect = SukiBackgroundEffect.FromString(ShaderCode); + else + _effect = SukiBackgroundEffect.FromEmbeddedResource(Style.ToString()); + } - public void Dispose() - { - _bmp.Dispose(); + protected override void OnUnloaded(RoutedEventArgs e) + { + _observables.Dispose(); + } } } \ No newline at end of file diff --git a/SukiUI/Controls/SukiWindow.axaml b/SukiUI/Controls/SukiWindow.axaml index 7be94b1cc..c63ee0536 100644 --- a/SukiUI/Controls/SukiWindow.axaml +++ b/SukiUI/Controls/SukiWindow.axaml @@ -3,7 +3,7 @@ xmlns:icons="clr-namespace:SukiUI.Content" xmlns:suki="clr-namespace:SukiUI.Controls"> - + @@ -11,12 +11,20 @@ - + - + @@ -30,9 +38,10 @@ - + CornerRadius="0" + IsAnimated="False" /> ? MenuItems set => SetValue(MenuItemsProperty, value); } - public static readonly StyledProperty BackgroundAnimationEnabledProperty = - AvaloniaProperty.Register(nameof(BackgroundAnimationEnabled), defaultValue: false); - - public bool BackgroundAnimationEnabled - { - get => GetValue(BackgroundAnimationEnabledProperty); - set => SetValue(BackgroundAnimationEnabledProperty, value); - } public static readonly StyledProperty CanMinimizeProperty = AvaloniaProperty.Register(nameof(CanMinimize), defaultValue: true); @@ -108,12 +102,62 @@ public bool CanMove set => SetValue(CanMoveProperty, value); } + // BACKGROUND PROPERTIES + public static readonly StyledProperty BackgroundAnimationEnabledProperty = + AvaloniaProperty.Register(nameof(BackgroundAnimationEnabled), defaultValue: false); + + public bool BackgroundAnimationEnabled + { + get => GetValue(BackgroundAnimationEnabledProperty); + set => SetValue(BackgroundAnimationEnabledProperty, value); + } + + public static readonly StyledProperty BackgroundStyleProperty = + AvaloniaProperty.Register(nameof(BackgroundStyle), + defaultValue: SukiBackgroundStyle.Waves); + + /// + /// Which of the default background styles to use. + /// + public SukiBackgroundStyle BackgroundStyle + { + get => GetValue(BackgroundStyleProperty); + set => SetValue(BackgroundStyleProperty, value); + } + + public static readonly StyledProperty BackgroundShaderFileProperty = + AvaloniaProperty.Register(nameof(BackgroundShaderFile)); + + /// + /// Specify a filename of an EMBEDDED RESOURCE file of type `.SkSL` with or without extension and it will be loaded and displayed. + /// This takes priority over the property, which in turns takes priority over . + /// + public string? BackgroundShaderFile + { + get => GetValue(BackgroundShaderFileProperty); + set => SetValue(BackgroundShaderFileProperty, value); + } + + public static readonly StyledProperty BackgroundShaderCodeProperty = + AvaloniaProperty.Register(nameof(BackgroundShaderCode)); + + /// + /// Specify the shader code to use directly, simpler if you don't want to create an .SkSL file or want to generate the shader effect at runtime in some way. + /// This takes priority over the property, but is second in priority to if it is set. + /// + public string? BackgroundShaderCode + { + get => GetValue(BackgroundShaderCodeProperty); + set => SetValue(BackgroundShaderCodeProperty, value); + } + public SukiWindow() { MenuItems = new AvaloniaList(); } private IDisposable? _subscriptionDisposables; + private SukiBackground _background; protected override void OnLoaded(RoutedEventArgs e) { @@ -132,46 +176,37 @@ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) { base.OnApplyTemplate(e); - var stateObs = this.GetObservable(WindowStateProperty) + _subscriptionDisposables = this.GetObservable(WindowStateProperty) .Do(OnWindowStateChanged) - .Select(_ => Unit.Default); -try{ - // Create handlers for buttons - if (e.NameScope.Get + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/SukiUI/Utilities/Background/FastNoiseBackgroundRenderer.cs b/SukiUI/Utilities/Background/FastNoiseBackgroundRenderer.cs deleted file mode 100644 index a765bcb69..000000000 --- a/SukiUI/Utilities/Background/FastNoiseBackgroundRenderer.cs +++ /dev/null @@ -1,174 +0,0 @@ -using Avalonia.Media; -using Avalonia.Media.Imaging; -using Avalonia.Styling; -using SukiUI.Models; -using System; -using System.Threading.Tasks; -using Avalonia; -using Avalonia.Threading; - -namespace SukiUI.Utilities.Background; - -public sealed class FastNoiseBackgroundRenderer : ISukiBackgroundRenderer -{ - public bool SupportsAnimation => true; - - private static readonly Random Rand = new(); - private static readonly FastNoiseLite NoiseGen = new(); - - private readonly object _lockObj = new(); - - private bool _isRedrawing; - - private uint _themeColor; - private uint _accentColor; - private uint _baseColor; - - private float _pOffsetX; - private float _pOffsetY; - - private float _aOffsetX; - private float _aOffsetY; - - private readonly float _scale; - private readonly float _xAnim; - private readonly float _yAnim; - private readonly float _primaryAlpha; - private readonly float _accentAlpha; - private readonly float _accentAlphaLight; - - public FastNoiseBackgroundRenderer(FastNoiseRendererOptions? options = null) - { - var opt = new FastNoiseRendererOptions(FastNoiseLite.NoiseType.OpenSimplex2); - NoiseGen.SetNoiseType(opt.Type); - _scale = opt.NoiseScale * 100f; - _xAnim = opt.XAnimSpeed; - _yAnim = opt.YAnimSpeed; - _primaryAlpha = opt.PrimaryAlpha; - _accentAlpha = opt.AccentAlpha; - _accentAlphaLight = opt.AccentAlphaLight; - } - - public void UpdateValues(SukiColorTheme colorTheme, ThemeVariant baseTheme) - { - _themeColor = colorTheme.Primary.ToUInt32(); - _accentColor = colorTheme.Accent.ToUInt32(); - _baseColor = baseTheme == ThemeVariant.Light - ? new Color(255, 241, 241, 241).ToUInt32() - : GetBackgroundColor(colorTheme.Primary); - - _pOffsetX = Rand.Next(1000); - _pOffsetY = Rand.Next(1000); - - _aOffsetY = Rand.Next(1000); - _aOffsetX = Rand.Next(1000); - } - - public async Task Render(WriteableBitmap bitmap, ThemeVariant baseTheme) - { - - - _pOffsetX += _xAnim; - _pOffsetY += _yAnim; - _aOffsetX -= _xAnim; - _aOffsetY -= _yAnim; - - if (_isRedrawing) return; - lock (_lockObj) { _isRedrawing = true; } - - await Task.Run(() => - { - using var frameBuffer = bitmap.Lock(); - var frameSize = frameBuffer.Size; - var frameScale = (1f / frameSize.Height) * _scale; - unsafe - { - var backBuffer = (uint*)frameBuffer.Address.ToPointer(); - var stride = frameBuffer.RowBytes / 4; - - Parallel.For((long)0, frameSize.Height, (scanline) => - { - var dest = backBuffer + scanline * stride + 0; - for (var x = 0; x < frameSize.Width; x++) - { - var noise = NoiseGen.GetNoise((_pOffsetX + x) * frameScale, (_pOffsetY + scanline) * frameScale); - noise = (noise + 1f) / 2f * _primaryAlpha; // noise returns -1 to +1 which isn't useful. - var alpha = (byte)(noise * 255); - var firstLayer = BlendPixelOverlay(WithAlpha(_themeColor, alpha), _baseColor); - - noise = NoiseGen.GetNoise((_aOffsetX + x) * frameScale, (_aOffsetY + scanline) * frameScale); - - - noise = (noise + 1f) / 2f * (baseTheme == ThemeVariant.Dark ? _accentAlpha : _accentAlphaLight) ; - - - alpha = (byte)(noise * 255); - - dest[x] = BlendPixel(WithAlpha(_accentColor, alpha), firstLayer); - } - }); - } - }); - lock (_lockObj) { _isRedrawing = false; } - } - - private static uint GetBackgroundColor(Color input) - { - int r = input.R; - int g = input.G; - int b = input.B; - - var minValue = Math.Min(Math.Min(r, g), b); - var maxValue = Math.Max(Math.Max(r, g), b); - - r = (r == minValue) ? 37 : ((r == maxValue) ? 37 : 26); - g = (g == minValue) ? 37 : ((g == maxValue) ? 37 : 26); - b = (b == minValue) ? 37 : ((b == maxValue) ? 37 : 26); - return ARGB(255, (byte)r, (byte)g, (byte)b); - } - - private static uint ARGB(byte a, byte r, byte g, byte b) => - (uint)(a << 24 | r << 16 | g << 8 | b << 0); - - private static byte A(uint col) => (byte)(col >> 24); - - private static byte R(uint col) => (byte)(col >> 16); - - private static byte G(uint col) => (byte)(col >> 8); - - private static byte B(uint col) => (byte)col; - - private static uint WithAlpha(uint col, byte a) => (col & 0x00FFFFFF) | (uint)(a << 24); - - private static uint BlendPixel(uint fore, uint back) - { - var alphaF = A(fore) / 255.0f; - - var resultR = (byte)(R(fore) * alphaF + R(back) * (1 - alphaF)); - var resultG = (byte)(G(fore) * alphaF + G(back) * (1 - alphaF)); - var resultB = (byte)(B(fore) * alphaF + B(back) * (1 - alphaF)); - var resultA = A(back); - - return ARGB(resultA, resultR, resultG, resultB); - } - - private static uint BlendPixelOverlay(uint fore, uint back) - { - var alphaF = A(fore) / 255.0f; - - var resultR = OverlayComponentBlend(R(fore), R(back), alphaF); - var resultG = OverlayComponentBlend(G(fore), G(back), alphaF); - var resultB = OverlayComponentBlend(B(fore), B(back), alphaF); - - return ARGB(A(back), resultR, resultG, resultB); - } - - private static byte OverlayComponentBlend(byte componentF, byte componentB, float alphaF) - { - var result = componentB <= 128 - ? 2 * componentF * componentB / 255.0f - : 255 - 2 * (255 - componentF) * (255 - componentB) / 255.0f; - - return (byte)(result * alphaF + componentB * (1 - alphaF)); - } -} \ No newline at end of file diff --git a/SukiUI/Utilities/Background/FastNoiseRendererOptions.cs b/SukiUI/Utilities/Background/FastNoiseRendererOptions.cs deleted file mode 100644 index fa3ceda6f..000000000 --- a/SukiUI/Utilities/Background/FastNoiseRendererOptions.cs +++ /dev/null @@ -1,40 +0,0 @@ -using Avalonia; -using Avalonia.Styling; - -namespace SukiUI.Utilities.Background; - -public readonly struct FastNoiseRendererOptions -{ - public FastNoiseLite.NoiseType Type { get; } - public float NoiseScale { get; } - public float XAnimSpeed { get; } - public float YAnimSpeed { get; } - public float PrimaryAlpha { get; } - public float AccentAlpha { get; } - public float AccentAlphaLight { get; } - - public FastNoiseRendererOptions( - FastNoiseLite.NoiseType type, - float noiseScale = 1.5f, - float xAnimSpeed = 2f, - float yAnimSpeed = 1f, - float primaryAlpha = 0.72f, - float accentAlpha = 0.08f, - float accentAlphaLight = 0.09f, - float animSeedScale = 0.1f - /* float noiseScale = 1f, - float xAnimSpeed = 0.05f, - float yAnimSpeed = 0.025f, - float primaryAlpha = 0.75f, - float accentAlpha = 0.2f, - float animSeedScale = 0.1f*/) - { - Type = type; - NoiseScale = noiseScale; - XAnimSpeed = xAnimSpeed * animSeedScale; - YAnimSpeed = yAnimSpeed * animSeedScale; - PrimaryAlpha = primaryAlpha; - AccentAlpha = accentAlpha; - AccentAlphaLight = accentAlphaLight; - } -} \ No newline at end of file diff --git a/SukiUI/Utilities/Background/ISukiBackgroundRenderer.cs b/SukiUI/Utilities/Background/ISukiBackgroundRenderer.cs deleted file mode 100644 index 73d9ada4b..000000000 --- a/SukiUI/Utilities/Background/ISukiBackgroundRenderer.cs +++ /dev/null @@ -1,35 +0,0 @@ -using Avalonia.Media.Imaging; -using Avalonia.Styling; -using SukiUI.Controls; -using SukiUI.Models; -using System.Threading.Tasks; - -namespace SukiUI.Utilities.Background; - -/// -/// Provides an interface for background renderers to be implemented behind. -/// -public interface ISukiBackgroundRenderer -{ - /// - /// Tells the control if this renderer should be animated. - /// - public bool SupportsAnimation { get; } - - /// - /// Updates the values from the main thread, allowing the generator to keep drawing in the background. - /// - /// - /// - public void UpdateValues(SukiColorTheme colorTheme, ThemeVariant baseTheme); - - /// - /// Called every time the Background control attempts to render the background. - /// This is called once at startup and... - /// If animation is enabled this will be called per-frame at 60fps. - /// If animation is disabled, this will be called every time the theme is changed. - /// - /// Bitmap to draw the background to. - /// - public Task Render(WriteableBitmap bitmap, ThemeVariant baseTheme); -} \ No newline at end of file diff --git a/SukiUI/Utilities/Background/ShaderBackgroundDraw.cs b/SukiUI/Utilities/Background/ShaderBackgroundDraw.cs new file mode 100644 index 000000000..d85c932e8 --- /dev/null +++ b/SukiUI/Utilities/Background/ShaderBackgroundDraw.cs @@ -0,0 +1,100 @@ +using System; +using System.Diagnostics; +using Avalonia; +using Avalonia.Media; +using Avalonia.Platform; +using Avalonia.Rendering.SceneGraph; +using Avalonia.Skia; +using Avalonia.Styling; +using SkiaSharp; + +namespace SukiUI.Utilities.Background +{ + internal class ShaderBackgroundDraw : ICustomDrawOperation + { + public Rect Bounds { get; internal set; } + + private SukiBackgroundEffect? _effect; + internal SukiBackgroundEffect? Effect + { + get => _effect; + set + { + _effect?.Dispose(); + _effect = value; + } + } + + private bool _animEnabled; + internal bool AnimEnabled + { + get => _animEnabled; + set + { + if (value) Sw.Start(); + else Sw.Stop(); + _animEnabled = value; + } + } + + private static readonly Stopwatch Sw = Stopwatch.StartNew(); + + private ThemeVariant _activeVariant = ThemeVariant.Dark; + + // TODO: Look more in depth at these + private static readonly float[] Black = { 0.1f, 0.1f, 0.1f }; + private static readonly float[] White = { 0.9f, 0.9f, 0.9f }; + + public ShaderBackgroundDraw(Rect bounds) + { + Bounds = bounds; + var sTheme = SukiTheme.GetInstance(); + sTheme.OnBaseThemeChanged += v => _activeVariant = v; + _activeVariant = SukiTheme.GetInstance().ActiveBaseTheme; + } + + public bool Equals(ICustomDrawOperation other) => false; + + public void Dispose() + { + Effect?.Dispose(); + } + + public bool HitTest(Point p) => false; + + public void Render(ImmediateDrawingContext context) + { + var leaseFeature = context.TryGetFeature(); + if (leaseFeature is null) throw new InvalidOperationException("Unable to lease Skia API"); + using var lease = leaseFeature.Lease(); + var canvas = lease.SkCanvas; + + canvas.Clear(SKColors.Transparent); + using var paint = new SKPaint(); + + if (_effect is not null) + { + var suki = SukiTheme.GetInstance(); + var acc = ToFloat(suki.ActiveColorTheme!.Accent); + var prim = ToFloat(suki.ActiveColorTheme.Primary); + var inputs = new SKRuntimeEffectUniforms(_effect.Effect) + { + { "iResolution", new[] { (float)Bounds.Width, (float)Bounds.Height, 0f } }, + { "iTime", (float)Sw.Elapsed.TotalSeconds * 0.1f }, + { "iBase", _activeVariant == ThemeVariant.Dark ? Black : White }, + { "iAccent", new[] { acc.r, acc.g, acc.b } }, + { "iPrimary", new[] { prim.r, prim.g, prim.b } } + }; + using var shader = _effect.Effect.ToShader(false, inputs); + paint.Shader = shader; + } + + canvas.DrawRect(SKRect.Create((float)Bounds.Width, (float)Bounds.Height), paint); + } + + private static (float r, float g, float b) ToFloat(Color col) + { + return (col.R / 255f, col.G / 255f, col.B / 255f); + } + } +} \ No newline at end of file diff --git a/SukiUI/Utilities/Background/SukiBackgroundEffect.cs b/SukiUI/Utilities/Background/SukiBackgroundEffect.cs new file mode 100644 index 000000000..bcd135263 --- /dev/null +++ b/SukiUI/Utilities/Background/SukiBackgroundEffect.cs @@ -0,0 +1,79 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using SkiaSharp; + +namespace SukiUI.Utilities.Background +{ + /// + /// Represents a shader used to render the background of a window. + /// The uniforms available will be injected automatically into every shader and these are the only ones that will be available. + /// float iTime | vec3 iResolution | vec3 iPrimary | vec3 iAccent | vec3 iBase + /// + public class SukiBackgroundEffect : IDisposable + { + private const string Uniforms = "uniform float iTime; // Scaled shader playback time (s)\n" + + "uniform vec3 iResolution; // Viewport resolution (pixels)\n" + + "uniform vec3 iPrimary; // Currently active primary color\n" + + "uniform vec3 iAccent; // Currently active accent color\n" + + "uniform vec3 iBase; // Currently active base color\n"; + + private readonly string _effectString; + internal SKRuntimeEffect Effect => SKRuntimeEffect.Create(_effectString, out _); + + private SukiBackgroundEffect(string content) + { + _effectString = content; + } + + /// + /// Attempts to load a ".sksl" shader file from the assembly. + /// You don't need to provide the extension. + /// REMEMBER: For files to be discoverable in the assembly they should be marked as an embedded resource. + /// + /// Name of the shader to load. + /// An instance of a SukiBackgroundShader with the loaded shader. + public static SukiBackgroundEffect FromEmbeddedResource(string shaderName) + { + shaderName = shaderName.ToLowerInvariant(); + if (!shaderName.EndsWith(".sksl")) + shaderName += ".sksl"; + var assembly = Assembly.GetEntryAssembly(); + var resName = assembly!.GetManifestResourceNames() + .FirstOrDefault(x => x.ToLowerInvariant().Contains(shaderName)); + if (resName is null) + { + assembly = Assembly.GetExecutingAssembly(); + resName = assembly.GetManifestResourceNames() + .FirstOrDefault(x => x.ToLowerInvariant().Contains(shaderName)); + } + if (resName is null) + throw new FileNotFoundException( + $"Unable to find a file with the name \"{shaderName}\" anywhere in the assembly."); + using var tr = new StreamReader(assembly.GetManifestResourceStream(resName)!); + return FromString(tr.ReadToEnd()); + } + + + public static SukiBackgroundEffect FromString(string shaderString) + { + var withUniforms = Uniforms + shaderString; + if (SKRuntimeEffect.Create(withUniforms, out var errors) is null) // used to check for compiler errors. + throw new SKShaderCompileException(errors); + return new SukiBackgroundEffect(withUniforms); + } + + public void Dispose() + { + Effect.Dispose(); + } + + public class SKShaderCompileException : Exception + { + public SKShaderCompileException(string message) : base(message) + { + } + } + } +} \ No newline at end of file