diff --git a/Avalonia.sln b/Avalonia.sln index 5ffa3dca50..2777f1b923 100644 --- a/Avalonia.sln +++ b/Avalonia.sln @@ -273,6 +273,12 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Headless.XUnit.Uni EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MobileSandbox.Browser", "samples\MobileSandbox.Browser\MobileSandbox.Browser.csproj", "{43FCC14E-EEBE-44B3-BCBC-F1C537EECBF8}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tizen", "Tizen", "{D1300000-7217-4693-8B0F-57CBD5814302}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Tizen", "src\Tizen\Avalonia.Tizen\Avalonia.Tizen.csproj", "{DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ControlCatalog.Tizen", "samples\ControlCatalog.Tizen\ControlCatalog.Tizen.csproj", "{A0B29221-2B6F-4B29-A4D5-2227811B5915}" +EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Avalonia.Metal", "src\Avalonia.Metal\Avalonia.Metal.csproj", "{60B4ED1F-ECFA-453B-8A70-1788261C8355}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Avalonia.Build.Tasks.UnitTest", "tests\Avalonia.Build.Tasks.UnitTest\Avalonia.Build.Tasks.UnitTest.csproj", "{B0FD6A48-FBAB-4676-B36A-DE76B0922B12}" @@ -670,6 +676,16 @@ Global {43FCC14E-EEBE-44B3-BCBC-F1C537EECBF8}.Debug|Any CPU.Build.0 = Debug|Any CPU {43FCC14E-EEBE-44B3-BCBC-F1C537EECBF8}.Release|Any CPU.ActiveCfg = Release|Any CPU {43FCC14E-EEBE-44B3-BCBC-F1C537EECBF8}.Release|Any CPU.Build.0 = Release|Any CPU + {DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Debug|Any CPU.Deploy.0 = Debug|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Release|Any CPU.Build.0 = Release|Any CPU + {A0B29221-2B6F-4B29-A4D5-2227811B5915}.Release|Any CPU.Deploy.0 = Release|Any CPU {60B4ED1F-ECFA-453B-8A70-1788261C8355}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {60B4ED1F-ECFA-453B-8A70-1788261C8355}.Debug|Any CPU.Build.0 = Debug|Any CPU {60B4ED1F-ECFA-453B-8A70-1788261C8355}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -764,6 +780,8 @@ Global {2999D79E-3C20-4A90-B651-CA7E0AC92D35} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {F83FC908-A4E3-40DE-B4CF-A4BA1E92CDB3} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {43FCC14E-EEBE-44B3-BCBC-F1C537EECBF8} = {9B9E3891-2366-4253-A952-D08BCEB71098} + {DFFBDBF5-5DBE-47ED-9EAE-D40B75AC99E8} = {D1300000-7217-4693-8B0F-57CBD5814302} + {A0B29221-2B6F-4B29-A4D5-2227811B5915} = {9B9E3891-2366-4253-A952-D08BCEB71098} {B0FD6A48-FBAB-4676-B36A-DE76B0922B12} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {9D6AEF22-221F-4F4B-B335-A4BA510F002C} = {C5A00AC3-B34C-4564-9BDD-2DA473EF4D8B} {5BF0C3B8-E595-4940-AB30-2DA206C2F085} = {9D6AEF22-221F-4F4B-B335-A4BA510F002C} diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 8bab6e68e2..12cfdd015f 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -45,6 +45,13 @@ jobs: script: | dotnet workload install wasm-tools wasm-experimental + - task: CmdLine@2 + displayName: 'Install Tizen Workload' + inputs: + targetType: 'inline' + script: | + curl -sSL https://raw.githubusercontent.com/Samsung/Tizen.NET/main/workload/scripts/workload-install.sh | sudo bash -s -- -d "/opt/hostedtoolcache/dotnet" + - task: CmdLine@2 displayName: 'Run Build' inputs: @@ -81,7 +88,14 @@ jobs: inputs: script: | dotnet workload install wasm-tools wasm-experimental - + + - task: CmdLine@2 + displayName: 'Install Tizen Workload' + inputs: + targetType: 'inline' + script: | + curl -sSL https://raw.githubusercontent.com/Samsung/Tizen.NET/main/workload/scripts/workload-install.sh | sudo bash + - task: CmdLine@2 displayName: 'Generate avalonia-native' inputs: @@ -153,6 +167,13 @@ jobs: script: | dotnet workload install android ios wasm-tools wasm-experimental + - task: PowerShell@2 + displayName: 'Install Tizen Workload' + inputs: + targetType: 'inline' + script: | + (New-Object System.Net.WebClient).DownloadString('https://raw.githubusercontent.com/Samsung/Tizen.NET/main/workload/scripts/workload-install.ps1') | Invoke-Expression + - task: CmdLine@2 displayName: 'Install Nuke' inputs: diff --git a/nukebuild/ApiDiffValidation.cs b/nukebuild/ApiDiffValidation.cs index 483bfd336a..ecffce47cb 100644 --- a/nukebuild/ApiDiffValidation.cs +++ b/nukebuild/ApiDiffValidation.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.IO.Compression; using System.Linq; +using System.Net; using System.Net.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; @@ -27,7 +28,10 @@ public static class ApiDiffValidation Directory.CreateDirectory(suppressionFilesFolder!); } - await using (var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion)) + await using var baselineStream = await DownloadBaselinePackage(packagePath, baselineVersion); + if (baselineStream == null) + return; + using (var target = new ZipArchive(File.Open(packagePath, FileMode.Open, FileAccess.Read), ZipArchiveMode.Read)) using (var baseline = new ZipArchive(baselineStream, ZipArchiveMode.Read)) using (Helpers.UseTempDir(out var tempFolder)) @@ -139,6 +143,10 @@ public static class ApiDiffValidation memoryStream.Seek(0, SeekOrigin.Begin); return memoryStream; } + catch (HttpRequestException e) when (e.StatusCode == HttpStatusCode.NotFound) + { + return null; + } catch (Exception ex) { throw new InvalidOperationException($"Downloading baseline package for {packageId} {baselineVersion} failed.\r" + ex.Message, ex); diff --git a/samples/ControlCatalog.Tizen/ControlCatalog.Tizen.csproj b/samples/ControlCatalog.Tizen/ControlCatalog.Tizen.csproj new file mode 100644 index 0000000000..20a93029ad --- /dev/null +++ b/samples/ControlCatalog.Tizen/ControlCatalog.Tizen.csproj @@ -0,0 +1,20 @@ + + + net6.0-tizen + Exe + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/ControlCatalog.Tizen/EmbedSampleNuiTizen.cs b/samples/ControlCatalog.Tizen/EmbedSampleNuiTizen.cs new file mode 100644 index 0000000000..728ea58e3b --- /dev/null +++ b/samples/ControlCatalog.Tizen/EmbedSampleNuiTizen.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Platform; +using Avalonia.Tizen; +using ControlCatalog.Pages; +using Tizen.NUI.BaseComponents; +using Tizen.NUI.Components; +using Tizen.Pims.Contacts.ContactsViews; + +namespace ControlCatalog.Tizen; +public class EmbedSampleNuiTizen : INativeDemoControl +{ + public IPlatformHandle CreateControl(bool isSecond, IPlatformHandle parent, Func createDefault) + { + if (isSecond) + { + var webView = new WebView(); + webView.LoadUrl("https://avaloniaui.net/"); + return new NuiViewControlHandle(webView); + } + else + { + var clickCount = 0; + var button = new Button + { + Text = "Hello world" + }; + + button.Clicked += (sender, e) => + { + clickCount++; + button.Text = $"Click count {clickCount}"; + }; + + return new NuiViewControlHandle(button); + } + } +} diff --git a/samples/ControlCatalog.Tizen/Main.cs b/samples/ControlCatalog.Tizen/Main.cs new file mode 100644 index 0000000000..2d611ef5a3 --- /dev/null +++ b/samples/ControlCatalog.Tizen/Main.cs @@ -0,0 +1,23 @@ +using System; +using Avalonia; +using Avalonia.Tizen; +using ElmSharp; +using SkiaSharp; +using Tizen.Applications; + +namespace ControlCatalog.Tizen; + +class Program : NuiTizenApplication +{ + protected override AppBuilder CustomizeAppBuilder(AppBuilder builder) => + base.CustomizeAppBuilder(builder).AfterSetup(_ => + { + Pages.EmbedSample.Implementation = new EmbedSampleNuiTizen(); + }); + + static void Main(string[] args) + { + var app = new Program(); + app.Run(args); + } +} diff --git a/samples/ControlCatalog.Tizen/shared/res/Avalonia.png b/samples/ControlCatalog.Tizen/shared/res/Avalonia.png new file mode 100644 index 0000000000..ea0bb4986f Binary files /dev/null and b/samples/ControlCatalog.Tizen/shared/res/Avalonia.png differ diff --git a/samples/ControlCatalog.Tizen/tizen-manifest.xml b/samples/ControlCatalog.Tizen/tizen-manifest.xml new file mode 100644 index 0000000000..5924d18bd6 --- /dev/null +++ b/samples/ControlCatalog.Tizen/tizen-manifest.xml @@ -0,0 +1,23 @@ + + + + + + Avalonia.png + + + + + + http://tizen.org/privilege/appdir.shareddata + http://tizen.org/privilege/appmanager.launch + http://tizen.org/privilege/externalstorage + http://tizen.org/privilege/externalstorage.appdata + http://tizen.org/privilege/internet + http://tizen.org/privilege/network.get + + + + http://tizen.org/feature/opengles.surfaceless_context + http://tizen.org/feature/opengles.version.2_0 + diff --git a/src/Avalonia.Base/Avalonia.Base.csproj b/src/Avalonia.Base/Avalonia.Base.csproj index 9aeb71f649..d7f740d1e4 100644 --- a/src/Avalonia.Base/Avalonia.Base.csproj +++ b/src/Avalonia.Base/Avalonia.Base.csproj @@ -54,6 +54,7 @@ + diff --git a/src/Tizen/Avalonia.Tizen/Avalonia.Tizen.csproj b/src/Tizen/Avalonia.Tizen/Avalonia.Tizen.csproj new file mode 100644 index 0000000000..7fd1d96097 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Avalonia.Tizen.csproj @@ -0,0 +1,20 @@ + + + + net6.0-tizen + enable + enable + true + + + + + + + + + + + + + diff --git a/src/Tizen/Avalonia.Tizen/ITizenView.cs b/src/Tizen/Avalonia.Tizen/ITizenView.cs new file mode 100644 index 0000000000..87ddba0b6b --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/ITizenView.cs @@ -0,0 +1,12 @@ +using Avalonia.Controls.Platform; +using Avalonia.Input; + +namespace Avalonia.Tizen; + +internal interface ITizenView +{ + Size ClientSize { get; } + double Scaling { get; } + IInputRoot InputRoot { get; set; } + INativeControlHostImpl NativeControlHost { get; } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiAvaloniaView.cs b/src/Tizen/Avalonia.Tizen/NuiAvaloniaView.cs new file mode 100644 index 0000000000..485d9bee79 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiAvaloniaView.cs @@ -0,0 +1,179 @@ +using Avalonia.Controls; +using Avalonia.Controls.Embedding; +using Avalonia.Controls.Platform; +using Avalonia.Input; +using Avalonia.Input.TextInput; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.Composition; +using Avalonia.Rendering.Composition.Server; +using Avalonia.Threading; +using Tizen.NUI; +using Tizen.NUI.BaseComponents; + +namespace Avalonia.Tizen; + +/// +/// Avalonia View for Tizen NUI controls +/// +public class NuiAvaloniaView : GLView, ITizenView, ITextInputMethodImpl +{ + private readonly NuiKeyboardHandler _keyboardHandler; + private readonly NuiTouchHandler _touchHandler; + private readonly NuiAvaloniaViewTextEditable _textEditor; + private TizenRenderTimer? _renderTimer; + private TopLevelImpl _topLevelImpl; + private EmbeddableControlRoot _topLevel; + private TouchDevice _device = new(); + private ServerCompositionTarget _compositionTargetServer; + + public IInputRoot InputRoot { get; set; } + public INativeControlHostImpl NativeControlHost { get; } + internal TopLevelImpl TopLevelImpl => _topLevelImpl; + public double Scaling => 1; + public Size ClientSize => new(Size.Width, Size.Height); + + public Control? Content + { + get => _topLevel.Content as Control; + set => _topLevel.Content = value; + } + + internal NuiAvaloniaViewTextEditable TextEditor => _textEditor; + internal NuiKeyboardHandler KeyboardHandler => _keyboardHandler; + + #region Setup + + public event Action OnSurfaceInit; + + public NuiAvaloniaView() : base(ColorFormat.RGBA8888) + { + RenderingMode = GLRenderingMode.OnDemand; + SetGraphicsConfig(true, true, 0, GLESVersion.Version30); + RegisterGLCallbacks(GlInit, GlRenderFrame, GlTerminate); + + _textEditor = new NuiAvaloniaViewTextEditable(this); + _keyboardHandler = new NuiKeyboardHandler(this); + _touchHandler = new NuiTouchHandler(this); + NativeControlHost = new NuiNativeControlHostImpl(this); + + Layout = new CustomLayout + { + SizeUpdated = OnResized + }; + + TouchEvent += OnTouchEvent; + WheelEvent += OnWheelEvent; + } + + private void GlInit() + { + OnSurfaceInit?.Invoke(); + } + + private int GlRenderFrame() + { + if (_renderTimer == null || _topLevel == null) + return 0; + + var rev = _compositionTargetServer.Revision; + _renderTimer.ManualTick(); + return rev == _compositionTargetServer.Revision ? 0 : 1; + } + + private void GlTerminate() + { + } + + internal void Initialise() + { + _topLevelImpl = new TopLevelImpl(this, new[] { new NuiGlLayerSurface(this) }); + _topLevelImpl.Compositor.AfterCommit += RenderOnce; + TizenPlatform.ThreadingInterface.TickExecuted += RenderOnce; + + _topLevel = new(_topLevelImpl); + _topLevel.Prepare(); + _topLevel.StartRendering(); + + _compositionTargetServer = ((CompositingRenderer)((IRenderRoot)_topLevel).Renderer).CompositionTarget.Server; + + _renderTimer = (TizenRenderTimer)AvaloniaLocator.Current.GetRequiredService(); + _renderTimer.RenderTick += RenderOnce; + + OnResized(); + } + + #endregion + + #region Resize and layout + + private class CustomLayout : AbsoluteLayout + { + float _width; + float _height; + + public Action? SizeUpdated { get; set; } + + protected override void OnLayout(bool changed, LayoutLength left, LayoutLength top, LayoutLength right, LayoutLength bottom) + { + var sizeChanged = _width != Owner.SizeWidth || _height != Owner.SizeHeight; + _width = Owner.SizeWidth; + _height = Owner.SizeHeight; + if (sizeChanged) + { + SizeUpdated?.Invoke(); + } + base.OnLayout(changed, left, top, right, bottom); + } + } + + protected void OnResized() + { + if (Size.Width == 0 || Size.Height == 0) + return; + + _topLevelImpl?.Resized?.Invoke(_topLevelImpl.ClientSize, WindowResizeReason.Layout); + } + + #endregion + + #region Event handlers + + private bool OnTouchEvent(object source, TouchEventArgs e) + { + _touchHandler?.Handle(e); + return true; + } + + private bool OnWheelEvent(object source, WheelEventArgs e) + { + _touchHandler?.Handle(e); + return true; + } + + public void SetClient(TextInputMethodClient? client) + { + _textEditor.SetClient(client); + } + + public void SetCursorRect(Rect rect) + { + } + + public void SetOptions(TextInputOptions options) => + _textEditor.SetOptions(options); + + #endregion + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _topLevel.StopRendering(); + _topLevel.Dispose(); + _topLevelImpl.Dispose(); + _device.Dispose(); + } + base.Dispose(disposing); + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs b/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs new file mode 100644 index 0000000000..da7b57e378 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiAvaloniaViewTextEditable.cs @@ -0,0 +1,205 @@ +using Avalonia.Input.TextInput; +using Tizen.NUI; +using Tizen.NUI.BaseComponents; +using Window = Tizen.NUI.Window; + +namespace Avalonia.Tizen; + +internal class NuiAvaloniaViewTextEditable +{ + private readonly NuiAvaloniaView _avaloniaView; + + private INuiTextInput TextInput => _multiline ? _multiLineTextInput : _singleLineTextInput; + private readonly NuiSingleLineTextInput _singleLineTextInput; + private readonly NuiMultiLineTextInput _multiLineTextInput; + private bool _updating; + private bool _keyboardPresented; + private bool _multiline; + + private TextInputMethodClient? _client; + + public bool IsActive => _client != null && _keyboardPresented; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + public NuiAvaloniaViewTextEditable(NuiAvaloniaView avaloniaView) +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + { + _avaloniaView = avaloniaView; + _singleLineTextInput = new NuiSingleLineTextInput + { + HeightResizePolicy = ResizePolicyType.Fixed, + WidthResizePolicy = ResizePolicyType.Fixed, + Size = new(1, 1), + Position = new Position(-1000, -1000), + FontSizeScale = 0.1f, + }; + + _multiLineTextInput = new NuiMultiLineTextInput + { + HeightResizePolicy = ResizePolicyType.Fixed, + WidthResizePolicy = ResizePolicyType.Fixed, + Size = new(1, 1), + Position = new Position(-1000, -1000), + FontSizeScale = 0.1f, + }; + + SetupTextInput(_singleLineTextInput); + SetupTextInput(_multiLineTextInput); + } + + private void SetupTextInput(INuiTextInput input) + { + input.Hide(); + + input.GetInputMethodContext().StatusChanged += OnStatusChanged; + input.GetInputMethodContext().EventReceived += OnEventReceived; + } + + private InputMethodContext.CallbackData OnEventReceived(object source, InputMethodContext.EventReceivedEventArgs e) + { + switch (e.EventData.EventName) + { + case InputMethodContext.EventType.Preedit: + _client?.SetPreeditText(e.EventData.PredictiveString); + break; + case InputMethodContext.EventType.Commit: + _client?.SetPreeditText(null); + _avaloniaView.TopLevelImpl.TextInput(e.EventData.PredictiveString); + break; + } + + return new InputMethodContext.CallbackData(); + } + + private void OnStatusChanged(object? sender, InputMethodContext.StatusChangedEventArgs e) + { + _keyboardPresented = e.StatusChanged; + if (!_keyboardPresented) + DettachAndHide(); + } + + internal void SetClient(TextInputMethodClient? client) + { + if (client == null || !_keyboardPresented) + DettachAndHide(); + + if (client != null) + AttachAndShow(client); + } + + internal void SetOptions(TextInputOptions options) + { + //TODO: This should be revert when Avalonia used Multiline property + _multiline = true; + //if (_multiline != options.Multiline) + //{ + // DettachAndHide(); + // _multiline = options.Multiline; + //} + + TextInput.Sensitive = options.IsSensitive; + } + + private void AttachAndShow(TextInputMethodClient client) + { + _updating = true; + try + { + TextInput.Text = client.SurroundingText; + TextInput.PrimaryCursorPosition = client.Selection.Start; + Window.Instance.GetDefaultLayer().Add((View)TextInput); + TextInput.Show(); + TextInput.EnableSelection = true; + + var inputContext = TextInput.GetInputMethodContext(); + inputContext.Activate(); + inputContext.ShowInputPanel(); + inputContext.RestoreAfterFocusLost(); + + _client = client; + client.TextViewVisualChanged += OnTextViewVisualChanged; + client.SurroundingTextChanged += OnSurroundingTextChanged; + client.SelectionChanged += OnClientSelectionChanged; + + TextInput.SelectWholeText(); + OnClientSelectionChanged(this, EventArgs.Empty); + } + finally { _updating = false; } + } + + private void OnClientSelectionChanged(object? sender, EventArgs e) => InvokeUpdate(client => + { + if (client.Selection.End == 0 || client.Selection.Start == client.Selection.End) + TextInput.PrimaryCursorPosition = client.Selection.Start; + else + TextInput.SelectText(client.Selection.Start, client.Selection.End); + }); + + private void OnSurroundingTextChanged(object? sender, EventArgs e) => InvokeUpdate(client => + { + TextInput.Text = client.SurroundingText; + TextInput.GetInputMethodContext().SetSurroundingText(client.SurroundingText); + OnClientSelectionChanged(sender, e); + }); + + private void OnTextViewVisualChanged(object? sender, EventArgs e) => InvokeUpdate(client => + { + TextInput.Text = client.SurroundingText; + }); + + private void DettachAndHide() + { + if (IsActive) + { + _client!.TextViewVisualChanged -= OnTextViewVisualChanged; + _client!.SurroundingTextChanged -= OnSurroundingTextChanged; + _client!.SelectionChanged -= OnClientSelectionChanged; + } + + if (Window.Instance.GetDefaultLayer().Children.Contains((View)TextInput)) + Window.Instance.GetDefaultLayer().Remove((View)TextInput); + + TextInput.Hide(); + + var inputContext = TextInput.GetInputMethodContext(); + inputContext.Deactivate(); + inputContext.HideInputPanel(); + } + + private void InvokeUpdate(Action action) + { + if (_updating || !IsActive) + return; + + _updating = true; + try + { + action(_client!); + } + finally { _updating = false; } + } +} + +internal interface INuiTextInput +{ + string Text { get; set; } + int PrimaryCursorPosition { get; set; } + bool EnableSelection { get; set; } + bool Sensitive { get; set; } + int SelectedTextStart { get; } + int SelectedTextEnd { get; } + + void Show(); + InputMethodContext GetInputMethodContext(); + void Hide(); + void SelectText(int selectedTextStart, int value); + void SelectWholeText(); +} + +public class NuiMultiLineTextInput : TextEditor, INuiTextInput +{ +} + +public class NuiSingleLineTextInput : TextField, INuiTextInput +{ +} diff --git a/src/Tizen/Avalonia.Tizen/NuiClipboardImpl.cs b/src/Tizen/Avalonia.Tizen/NuiClipboardImpl.cs new file mode 100644 index 0000000000..549582b034 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiClipboardImpl.cs @@ -0,0 +1,76 @@ +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Threading; +using Tizen.NUI; +using Tizen.NUI.BaseComponents; + +namespace Avalonia.Tizen; + +internal class NuiClipboardImpl : IClipboard +{ + private TextEditor _textEditor; + public NuiClipboardImpl() + { + _textEditor = new TextEditor() + { + HeightResizePolicy = ResizePolicyType.Fixed, + WidthResizePolicy = ResizePolicyType.Fixed, + Position = new Position(-1000, -1000), + Size = new(1, 1) + }; + + Window.Instance.GetDefaultLayer().Add(_textEditor); + _textEditor.LowerToBottom(); + } + + public Task ClearAsync() => + SetTextAsync(""); + + public Task GetTextAsync() + { + _textEditor.Show(); + _textEditor.Text = ""; + + //The solution suggested by Samsung, The method PasteTo will execute async and need delay + TextUtils.PasteTo(_textEditor); + + return Task.Run(async () => + { + await Task.Delay(10); + + return await Dispatcher.UIThread.InvokeAsync(() => + { + _textEditor.Hide(); + return _textEditor.Text; + }); + }); + } + + public Task SetTextAsync(string? text) + { + _textEditor.Show(); + _textEditor.Text = text; + + //The solution suggested by Samsung, The method SelectWholeText will execute async and need delay + _textEditor.SelectWholeText(); + + return Task.Run(async () => + { + await Task.Delay(10); + await Dispatcher.UIThread.InvokeAsync(() => + { + TextUtils.CopyToClipboard(_textEditor); + _textEditor.Hide(); + }); + }); + } + + public Task GetDataAsync(string format) => + throw new PlatformNotSupportedException(); + + public Task SetDataObjectAsync(IDataObject data) => + throw new PlatformNotSupportedException(); + + public Task GetFormatsAsync() => + throw new PlatformNotSupportedException(); +} diff --git a/src/Tizen/Avalonia.Tizen/NuiGlLayerSurface.cs b/src/Tizen/Avalonia.Tizen/NuiGlLayerSurface.cs new file mode 100644 index 0000000000..5f4a222e3d --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiGlLayerSurface.cs @@ -0,0 +1,73 @@ +using Avalonia.OpenGL; +using Avalonia.OpenGL.Surfaces; + +namespace Avalonia.Tizen; +internal class NuiGlLayerSurface : IGlPlatformSurface +{ + private readonly NuiAvaloniaView _nuiAvaloniaView; + + public NuiGlLayerSurface(NuiAvaloniaView nuiAvaloniaView) + { + _nuiAvaloniaView = nuiAvaloniaView; + } + + public IGlPlatformSurfaceRenderTarget CreateGlRenderTarget(IGlContext context) + { + var ctx = TizenPlatform.GlPlatform.Context; + if (ctx != context) + throw new InvalidOperationException("Platform surface is only usable with tha main context"); + using (ctx.MakeCurrent()) + { + return new RenderTarget(ctx, _nuiAvaloniaView); + } + } + + class RenderTarget : IGlPlatformSurfaceRenderTarget + { + private readonly GlContext _ctx; + private readonly NuiAvaloniaView _nuiAvaloniaView; + + public RenderTarget(GlContext ctx, NuiAvaloniaView nuiAvaloniaView) + { + _ctx = ctx; + _nuiAvaloniaView = nuiAvaloniaView; + } + + public void Dispose() + { + + } + + public IGlPlatformSurfaceRenderingSession BeginDraw() + { + var restoreContext = _ctx.MakeCurrent(); + return new RenderSession(_ctx, restoreContext, _nuiAvaloniaView); + } + } + + class RenderSession : IGlPlatformSurfaceRenderingSession + { + private readonly GlContext _ctx; + private readonly IDisposable _restoreContext; + + public RenderSession(GlContext ctx, IDisposable restoreContext, NuiAvaloniaView nuiAvaloniaView) + { + _ctx = ctx; + _restoreContext = restoreContext; + Size = new PixelSize((int)nuiAvaloniaView.Size.Width, (int)nuiAvaloniaView.Size.Height); + Scaling = nuiAvaloniaView.Scaling; + Context = ctx; + } + + public void Dispose() + { + _ctx.GlInterface.Finish(); + _restoreContext.Dispose(); + } + + public IGlContext Context { get; } + public PixelSize Size { get; } + public double Scaling { get; } + public bool IsYFlipped { get; } + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiGlPlatform.cs b/src/Tizen/Avalonia.Tizen/NuiGlPlatform.cs new file mode 100644 index 0000000000..db48c626ca --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiGlPlatform.cs @@ -0,0 +1,87 @@ +using Avalonia.Compatibility; +using Avalonia.OpenGL; +using Avalonia.Platform; + +namespace Avalonia.Tizen; +internal class NuiGlPlatform : IPlatformGraphics +{ + + public IPlatformGraphicsContext GetSharedContext() => Context; + + public bool UsesSharedContext => true; + public IPlatformGraphicsContext CreateContext() => throw new NotSupportedException(); + public GlContext Context { get; } + public static GlVersion GlVersion { get; } = new(GlProfileType.OpenGLES, 3, 0); + + public NuiGlPlatform() + { + const string library = "/usr/lib/driver/libGLESv2.so"; + var libGl = NativeLibraryEx.Load(library); + if (libGl == IntPtr.Zero) + throw new OpenGlException("Unable to load " + library); + var iface = new GlInterface(GlVersion, proc => + { + if (NativeLibraryEx.TryGetExport(libGl, proc, out var address)) + return address; + return default; + }); + Context = new(iface); + } +} + +class GlContext : IGlContext +{ + public GlContext(GlInterface glInterface) + { + GlInterface = glInterface; + } + + public void Dispose() + { + } + + public IDisposable MakeCurrent() + { + return this; + } + + public bool IsLost => false; + + public IDisposable EnsureCurrent() + { + return MakeCurrent(); + } + + public bool IsSharedWith(IGlContext context) => true; + public bool CanCreateSharedContext => true; + public IGlContext CreateSharedContext(IEnumerable preferredVersions = null) + { + return this; + } + + public GlVersion Version => new GlVersion(GlProfileType.OpenGLES, 3, 0); + public GlInterface GlInterface { get; } + public int SampleCount + { + get + { + GlInterface.GetIntegerv(GlConsts.GL_SAMPLES, out var samples); + return samples; + } + } + public int StencilSize + { + get + { + GlInterface.GetIntegerv(GlConsts.GL_STENCIL_BITS, out var stencil); + return stencil; + } + } + + public object TryGetFeature(Type featureType) => null; + + public IntPtr GetProcAddress(string procName) + { + return GlInterface.GetProcAddress(procName); + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiKeyboardHandler.cs b/src/Tizen/Avalonia.Tizen/NuiKeyboardHandler.cs new file mode 100644 index 0000000000..f0fbbbf563 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiKeyboardHandler.cs @@ -0,0 +1,102 @@ +using Avalonia.Input; +using Avalonia.Input.Raw; +using Avalonia.Logging; +using Avalonia.Tizen.Platform.Input; +using Tizen.NUI; +using Key = Tizen.NUI.Key; + +namespace Avalonia.Tizen; +internal class NuiKeyboardHandler +{ + private const string LogKey = "TIZENHKEY"; + + private readonly NuiAvaloniaView _view; + + public NuiKeyboardHandler(NuiAvaloniaView view) + { + _view = view; + } + + public void Handle(Window.KeyEventArgs e) + { + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Key fired {text}", e.Key.KeyPressedName); + + if (_view.TextEditor.IsActive) + return; + + if (ShouldSendKeyEvent(e, out var keyCode)) + { + var mapped = TizenKeyboardDevice.ConvertKey(keyCode); + if (mapped == Input.Key.None) + return; + + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Triggering key event {text}", e.Key.KeyString); + SendKeyEvent(e, mapped); + } + else if (e.Key.State == Key.StateType.Up) + { + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Triggering text input {text}", e.Key.KeyString); + _view.TopLevelImpl.TextInput(e.Key.KeyString); + } + } + + private void SendKeyEvent(Window.KeyEventArgs e, Input.Key mapped) + { + var type = GetKeyEventType(e); + var modifiers = GetModifierKey(e); + + _view.TopLevelImpl.Input?.Invoke( + new RawKeyEventArgs( + KeyboardDevice.Instance!, + e.Key.Time, + _view.InputRoot, + type, + mapped, + modifiers)); + } + + private bool ShouldSendKeyEvent(Window.KeyEventArgs e, out global::Tizen.Uix.InputMethod.KeyCode keyCode) + { + if (string.IsNullOrEmpty(e.Key.KeyString) || + e.Key.KeyPressedName is "Delete" or "BackSpace" || + e.Key.IsCtrlModifier() || + e.Key.IsAltModifier()) + { + if (Enum.TryParse(e.Key.KeyPressedName, true, out keyCode) || + Enum.TryParse($"Keypad{e.Key.KeyPressedName}", true, out keyCode)) + return true; + } + + keyCode = 0; + return false; + } + + private RawKeyEventType GetKeyEventType(Window.KeyEventArgs ev) + { + switch (ev.Key.State) + { + case Key.StateType.Down: + return RawKeyEventType.KeyDown; + case Key.StateType.Up: + return RawKeyEventType.KeyUp; + default: + throw new ArgumentOutOfRangeException(); + } + } + + private RawInputModifiers GetModifierKey(Window.KeyEventArgs ev) + { + var modifiers = RawInputModifiers.None; + + if (ev.Key.IsShiftModifier()) + modifiers |= RawInputModifiers.Shift; + + if (ev.Key.IsAltModifier()) + modifiers |= RawInputModifiers.Alt; + + if (ev.Key.IsCtrlModifier()) + modifiers |= RawInputModifiers.Control; + + return modifiers; + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiNativeControlHostImpl.cs b/src/Tizen/Avalonia.Tizen/NuiNativeControlHostImpl.cs new file mode 100644 index 0000000000..ac94dc14d7 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiNativeControlHostImpl.cs @@ -0,0 +1,117 @@ +using System.Diagnostics.CodeAnalysis; +using Avalonia.Controls.Platform; +using Avalonia.Platform; +using Tizen.NUI.BaseComponents; + +namespace Avalonia.Tizen; + +internal class NuiNativeControlHostImpl : INativeControlHostImpl +{ + private readonly NuiAvaloniaView _avaloniaView; + + public NuiNativeControlHostImpl(NuiAvaloniaView avaloniaView) + { + _avaloniaView = avaloniaView; + } + + public INativeControlHostDestroyableControlHandle CreateDefaultChild(IPlatformHandle parent) => + new NuiViewControlHandle(new View()); + + public INativeControlHostControlTopLevelAttachment CreateNewAttachment(Func create) + { + var parent = new NuiViewControlHandle(_avaloniaView); + NativeControlAttachment? attachment = null; + try + { + var child = create(parent); + // It has to be assigned to the variable before property setter is called so we dispose it on exception +#pragma warning disable IDE0017 // Simplify object initialization + attachment = new NativeControlAttachment(child); +#pragma warning restore IDE0017 // Simplify object initialization + attachment.AttachedTo = this; + return attachment; + } + catch + { + attachment?.Dispose(); + throw; + } + } + + public INativeControlHostControlTopLevelAttachment CreateNewAttachment(IPlatformHandle handle) => + new NativeControlAttachment(handle); + + public bool IsCompatibleWith(IPlatformHandle handle) => + handle.HandleDescriptor == NuiViewControlHandle.ViewDescriptor; + + private class NativeControlAttachment : INativeControlHostControlTopLevelAttachment + { + private IPlatformHandle? _child; + private View? _view; + private NuiNativeControlHostImpl? _attachedTo; + + public NativeControlAttachment(IPlatformHandle child) + { + _child = child; + _view = (child as NuiViewControlHandle)?.View; + } + + [MemberNotNull(nameof(_view))] + private void CheckDisposed() + { + if (_view == null) + throw new ObjectDisposedException(nameof(NativeControlAttachment)); + } + + public void Dispose() + { + _view?.Unparent(); + _child = null; + _attachedTo = null; + _view?.Dispose(); + _view = null; + } + + public INativeControlHostImpl? AttachedTo + { + get => _attachedTo; + set + { + CheckDisposed(); + + _attachedTo = (NuiNativeControlHostImpl?)value; + if (_attachedTo == null) + { + _view.Unparent(); + } + else + { + _attachedTo._avaloniaView.Add(_view); + } + } + } + + public bool IsCompatibleWith(INativeControlHostImpl host) => host is NuiNativeControlHostImpl; + + public void HideWithSize(Size size) + { + CheckDisposed(); + if (_attachedTo == null) + return; + + _view.Hide(); + _view.Size = new global::Tizen.NUI.Size(MathF.Max(1f, (float)size.Width), Math.Max(1f, (float)size.Height)); + } + + public void ShowInBounds(Rect bounds) + { + CheckDisposed(); + if (_attachedTo == null) + throw new InvalidOperationException("The control isn't currently attached to a toplevel"); + + _view.Size = new global::Tizen.NUI.Size(MathF.Max(1f, (float)bounds.Width), Math.Max(1f, (float)bounds.Height)); + _view.Position = new global::Tizen.NUI.Position((float)bounds.X, (float)bounds.Y); + _view.Show(); + } + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiTizenApplication.cs b/src/Tizen/Avalonia.Tizen/NuiTizenApplication.cs new file mode 100644 index 0000000000..c35da77e10 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiTizenApplication.cs @@ -0,0 +1,73 @@ +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using Tizen.NUI; +using Window = Tizen.NUI.Window; +using Avalonia.Logging; +using Avalonia.Threading; + +namespace Avalonia.Tizen; + +public class NuiTizenApplication : NUIApplication + where TApp : Application, new() +{ + private const string LogKey = "TIZENAPP"; + + private SingleViewLifetime? _lifetime; + + private class SingleViewLifetime : ISingleViewApplicationLifetime + { + public NuiAvaloniaView View { get; } + + public SingleViewLifetime(NuiAvaloniaView view) + { + View = view; + } + + public Control MainView + { + get => View.Content; + set => View.Content = value; + } + } + + protected virtual AppBuilder CustomizeAppBuilder(AppBuilder builder) => builder; + + protected override void OnCreate() + { + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Creating application"); + + base.OnCreate(); +#pragma warning disable CS8601 // Possible null reference assignment. + TizenThreadingInterface.MainloopContext = SynchronizationContext.Current; +#pragma warning restore CS8601 // Possible null reference assignment. + + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Setup view"); + _lifetime = new SingleViewLifetime(new NuiAvaloniaView()); + + _lifetime.View.HeightResizePolicy = ResizePolicyType.FillToParent; + _lifetime.View.WidthResizePolicy = ResizePolicyType.FillToParent; + _lifetime.View.OnSurfaceInit += ContinueSetupApplication; + + Window.Instance.RenderingBehavior = RenderingBehaviorType.Continuously; + Window.Instance.GetDefaultLayer().Add(_lifetime.View); + Window.Instance.KeyEvent += (s, e) => _lifetime?.View?.KeyboardHandler.Handle(e); + } + + private void ContinueSetupApplication() + { + SynchronizationContext.SetSynchronizationContext(new SynchronizationContext()); + + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "App builder"); + var builder = AppBuilder.Configure().UseTizen(); + + TizenThreadingInterface.MainloopContext.Post(_ => + { + CustomizeAppBuilder(builder); + + builder.AfterSetup(_ => _lifetime.View.Initialise()); + + Logger.TryGet(LogEventLevel.Debug, LogKey)?.Log(null, "Setup lifetime"); + builder.SetupWithLifetime(_lifetime); + }, null); + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiTouchHandler.cs b/src/Tizen/Avalonia.Tizen/NuiTouchHandler.cs new file mode 100644 index 0000000000..bf3156b2f0 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiTouchHandler.cs @@ -0,0 +1,99 @@ +using Avalonia.Input; +using Avalonia.Input.Raw; +using Tizen.NUI; +using static Tizen.NUI.BaseComponents.View; + +namespace Avalonia.Tizen; +internal class NuiTouchHandler +{ + private readonly NuiAvaloniaView _view; + public TouchDevice _device = new TouchDevice(); + + public NuiTouchHandler(NuiAvaloniaView view) + { + _view = view; + } + + private IInputRoot InputRoot => _view.InputRoot; + private static uint _nextTouchPointId = 1; + private List _knownTouches = new(); + + public void Handle(TouchEventArgs e) + { + var count = e.Touch.GetPointCount(); + for (var i = 0u; i < count; i++) + { + uint id; + if (_knownTouches.Count > i) + { + id = _knownTouches[(int)i]; + } + else + { + unchecked + { + id = _nextTouchPointId++; + } + _knownTouches.Add(id); + } + + var point = e.Touch.GetLocalPosition(i); + var state = e.Touch.GetState(i); + var timestamp = e.Touch.GetTime(); + var avaloniaState = state switch + { + PointStateType.Down => RawPointerEventType.TouchBegin, + PointStateType.Up => RawPointerEventType.TouchEnd, + PointStateType.Motion => RawPointerEventType.TouchUpdate, + PointStateType.Interrupted => RawPointerEventType.TouchCancel, + _ => RawPointerEventType.TouchUpdate + }; + + var touchEvent = new RawTouchEventArgs( + _device, + timestamp, + InputRoot, + avaloniaState, + new Point(point.X, point.Y), + RawInputModifiers.None, + id); + _view.TopLevelImpl.Input?.Invoke(touchEvent); + + if (state is PointStateType.Up or PointStateType.Interrupted) + { + _knownTouches.Remove(id); + } + } + } + + public void Handle(WheelEventArgs e) + { + var mouseWheelEvent = new RawMouseWheelEventArgs( + _device, + e.Wheel.TimeStamp, + InputRoot, + new Point(e.Wheel.Point.X, e.Wheel.Point.Y), + new Vector( + e.Wheel.Direction == 1 ? e.Wheel.Z : 0, + e.Wheel.Direction == 0 ? e.Wheel.Z : 0), + GetModifierKey(e)); + + _view.TopLevelImpl.Input?.Invoke(mouseWheelEvent); + } + + private RawInputModifiers GetModifierKey(WheelEventArgs ev) + { + var modifiers = RawInputModifiers.None; + + if (ev.Wheel.IsShiftModifier()) + modifiers |= RawInputModifiers.Shift; + + if (ev.Wheel.IsAltModifier()) + modifiers |= RawInputModifiers.Alt; + + if (ev.Wheel.IsCtrlModifier()) + modifiers |= RawInputModifiers.Control; + + return modifiers; + } +} diff --git a/src/Tizen/Avalonia.Tizen/NuiViewControlHandle.cs b/src/Tizen/Avalonia.Tizen/NuiViewControlHandle.cs new file mode 100644 index 0000000000..66aa70ec87 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/NuiViewControlHandle.cs @@ -0,0 +1,39 @@ +using Avalonia.Controls.Platform; +using Tizen.NUI.BaseComponents; + +namespace Avalonia.Tizen; + +/// +/// Tizen Nui native view handle for native view attachment +/// +public class NuiViewControlHandle : INativeControlHostDestroyableControlHandle +{ + internal const string ViewDescriptor = "NuiView"; + + /// + /// Create handle with native view + /// + /// NUI Tizen native view to attach + public NuiViewControlHandle(View view) + { + View = view; + } + + /// + /// NUI Tizen View + /// + public View View { get; set; } + /// + /// NUI Tizen not supporting handle + /// + /// + public IntPtr Handle => throw new NotSupportedException(); + /// + /// Return `ViewDescriptor` all the time + /// + public string? HandleDescriptor => ViewDescriptor; + /// + /// Dispose Tizen View when it call + /// + public void Destroy() => View.Dispose(); +} diff --git a/src/Tizen/Avalonia.Tizen/Platform/Consts.cs b/src/Tizen/Avalonia.Tizen/Platform/Consts.cs new file mode 100644 index 0000000000..3cc6205fcc --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Platform/Consts.cs @@ -0,0 +1,29 @@ +namespace Avalonia.Tizen.Platform; +internal static class Consts +{ + public const int DpiX = 96; + public const int DpiY = 96; + public static readonly Vector Dpi = new Vector(DpiX, DpiY); + + public const string VertexShader = + "attribute mediump vec2 aPosition;\n" + + "varying mediump vec2 vTexCoord;\n" + + "uniform highp mat4 uMvpMatrix;\n" + + "uniform mediump vec3 uSize;\n" + + "varying mediump vec2 sTexCoordRect;\n" + + "void main()\n" + + "{\n" + + " gl_Position = uMvpMatrix * vec4(aPosition * uSize.xy, 0.0, 1.0);\n" + + " vTexCoord = aPosition + vec2(0.5);\n" + + "}\n"; + + public const string FragmentShader = + "#extension GL_OES_EGL_image_external:require\n" + + "uniform lowp vec4 uColor;\n" + + "varying mediump vec2 vTexCoord;\n" + + "uniform samplerExternalOES sTexture;\n" + + "void main()\n" + + "{\n" + + " gl_FragColor = texture2D(sTexture, vTexCoord) * uColor;\n" + + "}\n"; +} diff --git a/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs b/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs new file mode 100644 index 0000000000..6d595014a1 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Platform/Input/TizenKeyboardDevice.cs @@ -0,0 +1,221 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Avalonia.Input; +using Tizen.Uix.InputMethod; + +namespace Avalonia.Tizen.Platform.Input; +internal class TizenKeyboardDevice: KeyboardDevice, IKeyboardDevice +{ + private static readonly Dictionary KeyDic = new Dictionary + { + // { KeyCode.Cancel?, Key.Cancel }, + { KeyCode.BackSpace, Key.Back }, + { KeyCode.Tab, Key.Tab }, + // { KeyCode.Linefeed?, Key.LineFeed }, + { KeyCode.Clear, Key.Clear }, + { KeyCode.Return, Key.Return }, + { KeyCode.Pause, Key.Pause }, + { KeyCode.CapsLock, Key.CapsLock }, + //{ KeyCode.?, Key.HangulMode } + //{ KeyCode.?, Key.JunjaMode } + //{ KeyCode.?, Key.FinalMode } + //{ KeyCode.?, Key.KanjiMode } + { KeyCode.Escape, Key.Escape }, + //{ KeyCode.?, Key.ImeConvert } + //{ KeyCode.?, Key.ImeNonConvert } + //{ KeyCode.?, Key.ImeAccept } + //{ KeyCode.?, Key.ImeModeChange } + { KeyCode.Space, Key.Space }, + { KeyCode.Page_Up, Key.Prior }, + { KeyCode.Page_Down, Key.PageDown }, + { KeyCode.End, Key.End }, + { KeyCode.Home, Key.Home }, + { KeyCode.Left, Key.Left }, + { KeyCode.Up, Key.Up }, + { KeyCode.Right, Key.Right }, + { KeyCode.Down, Key.Down }, + // { KeyCode.ButtonSelect?, Key.Select }, + // { KeyCode.print?, Key.Print }, + //{ KeyCode.execute?, Key.Execute }, + //{ KeyCode.snap?, Key.Snapshot } + { KeyCode.Insert, Key.Insert }, + { KeyCode.Delete, Key.Delete }, + { KeyCode.Help, Key.Help }, + { KeyCode.Keypad0, Key.D0 }, + { KeyCode.Keypad1, Key.D1 }, + { KeyCode.Keypad2, Key.D2 }, + { KeyCode.Keypad3, Key.D3 }, + { KeyCode.Keypad4, Key.D4 }, + { KeyCode.Keypad5, Key.D5 }, + { KeyCode.Keypad6, Key.D6 }, + { KeyCode.Keypad7, Key.D7 }, + { KeyCode.Keypad8, Key.D8 }, + { KeyCode.Keypad9, Key.D9 }, + { KeyCode.KeypadA, Key.A }, + { KeyCode.KeypadB, Key.B }, + { KeyCode.KeypadC, Key.C }, + { KeyCode.KeypadD, Key.D }, + { KeyCode.KeypadE, Key.E }, + { KeyCode.KeypadF, Key.F }, + { KeyCode.KeypadG, Key.G }, + { KeyCode.KeypadH, Key.H }, + { KeyCode.KeypadI, Key.I }, + { KeyCode.KeypadJ, Key.J }, + { KeyCode.KeypadK, Key.K }, + { KeyCode.KeypadL, Key.L }, + { KeyCode.KeypadM, Key.M }, + { KeyCode.KeypadN, Key.N }, + { KeyCode.KeypadO, Key.O }, + { KeyCode.KeypadP, Key.P }, + { KeyCode.KeypadQ, Key.Q }, + { KeyCode.KeypadR, Key.R }, + { KeyCode.KeypadS, Key.S }, + { KeyCode.KeypadT, Key.T }, + { KeyCode.KeypadU, Key.U }, + { KeyCode.KeypadV, Key.V }, + { KeyCode.KeypadW, Key.W }, + { KeyCode.KeypadX, Key.X }, + { KeyCode.KeypadY, Key.Y }, + { KeyCode.KeypadZ, Key.Z }, + { KeyCode.Keypada, Key.A }, + { KeyCode.Keypadb, Key.B }, + { KeyCode.Keypadc, Key.C }, + { KeyCode.Keypadd, Key.D }, + { KeyCode.Keypade, Key.E }, + { KeyCode.Keypadf, Key.F }, + { KeyCode.Keypadg, Key.G }, + { KeyCode.Keypadh, Key.H }, + { KeyCode.Keypadi, Key.I }, + { KeyCode.Keypadj, Key.J }, + { KeyCode.Keypadk, Key.K }, + { KeyCode.Keypadl, Key.L }, + { KeyCode.Keypadm, Key.M }, + { KeyCode.Keypadn, Key.N }, + { KeyCode.Keypado, Key.O }, + { KeyCode.Keypadp, Key.P }, + { KeyCode.Keypadq, Key.Q }, + { KeyCode.Keypadr, Key.R }, + { KeyCode.Keypads, Key.S }, + { KeyCode.Keypadt, Key.T }, + { KeyCode.Keypadu, Key.U }, + { KeyCode.Keypadv, Key.V }, + { KeyCode.Keypadw, Key.W }, + { KeyCode.Keypadx, Key.X }, + { KeyCode.Keypady, Key.Y }, + { KeyCode.Keypadz, Key.Z }, + //{ KeyCode.?, Key.LWin } + //{ KeyCode.?, Key.RWin } + //{ KeyCode.?, Key.Apps } + //{ KeyCode.Sle, Key.Sleep }, + { KeyCode.KP0, Key.NumPad0 }, + { KeyCode.KP1, Key.NumPad1 }, + { KeyCode.KP2, Key.NumPad2 }, + { KeyCode.KP3, Key.NumPad3 }, + { KeyCode.KP4, Key.NumPad4 }, + { KeyCode.KP5, Key.NumPad5 }, + { KeyCode.KP6, Key.NumPad6 }, + { KeyCode.KP7, Key.NumPad7 }, + { KeyCode.KP8, Key.NumPad8 }, + { KeyCode.KP9, Key.NumPad9 }, + { KeyCode.KPMultiply, Key.Multiply }, + { KeyCode.KPAdd, Key.Add }, + { KeyCode.KPSeparator, Key.Separator }, + { KeyCode.KPSubtract, Key.Subtract }, + { KeyCode.KPDecimal, Key.Decimal }, + { KeyCode.KPDivide, Key.Divide }, + { KeyCode.F1, Key.F1 }, + { KeyCode.F2, Key.F2 }, + { KeyCode.F3, Key.F3 }, + { KeyCode.F4, Key.F4 }, + { KeyCode.F5, Key.F5 }, + { KeyCode.F6, Key.F6 }, + { KeyCode.F7, Key.F7 }, + { KeyCode.F8, Key.F8 }, + { KeyCode.F9, Key.F9 }, + { KeyCode.F10, Key.F10 }, + { KeyCode.F11, Key.F11 }, + { KeyCode.F12, Key.F12 }, + //{ KeyCode.f13, Key.F13 }, + //{ KeyCode.F14, Key.F14 }, + //{ KeyCode.L5, Key.F15 }, + //{ KeyCode.F16, Key.F16 }, + //{ KeyCode.F17, Key.F17 }, + //{ KeyCode.L8, Key.F18 }, + //{ KeyCode.L9, Key.F19 }, + //{ KeyCode.L10, Key.F20 }, + //{ KeyCode.R1, Key.F21 }, + //{ KeyCode.R2, Key.F22 }, + //{ KeyCode.F23, Key.F23 }, + //{ KeyCode.R4, Key.F24 }, + { KeyCode.Num_Lock, Key.NumLock }, + { KeyCode.ScrollLock, Key.Scroll }, + { KeyCode.ShiftL, Key.LeftShift }, + { KeyCode.ShiftR, Key.RightShift }, + { KeyCode.ControlL, Key.LeftCtrl }, + { KeyCode.ControlR, Key.RightCtrl }, + { KeyCode.AltL, Key.LeftAlt }, + { KeyCode.AltR, Key.RightAlt }, + //{ KeyCode.?, Key.BrowserBack } + //{ KeyCode.?, Key.BrowserForward } + //{ KeyCode.?, Key.BrowserRefresh } + //{ KeyCode.?, Key.BrowserStop } + //{ KeyCode.?, Key.BrowserSearch } + //{ KeyCode.?, Key.BrowserFavorites } + //{ KeyCode.?, Key.BrowserHome } + //{ KeyCode.?, Key.VolumeMute } + //{ KeyCode.VolumeDown, Key.VolumeDown }, + //{ KeyCode.VolumeUp, Key.VolumeUp }, + //{ KeyCode.MediaNext, Key.MediaNextTrack }, + //{ KeyCode.MediaPrevious, Key.MediaPreviousTrack }, + //{ KeyCode.MediaStop, Key.MediaStop }, + //{ KeyCode.MediaPlayPause, Key.MediaPlayPause }, + //{ KeyCode.?, Key.LaunchMail } + //{ KeyCode.?, Key.SelectMedia } + //{ KeyCode.?, Key.LaunchApplication1 } + //{ KeyCode.?, Key.LaunchApplication2 } + { KeyCode.Semicolon, Key.OemSemicolon }, + { KeyCode.Plus, Key.OemPlus }, + { KeyCode.Comma, Key.OemComma }, + { KeyCode.Minus, Key.OemMinus }, + { KeyCode.Period, Key.OemPeriod }, + //{ KeyCode.?, Key.Oem2 } + { KeyCode.Grave, Key.OemTilde }, + //{ KeyCode.?, Key.AbntC1 } + //{ KeyCode.?, Key.AbntC2 } + //{ KeyCode.?, Key.OemPipe } + { KeyCode.Apostrophe, Key.OemQuotes }, + { KeyCode.Slash, Key.OemQuestion }, + { KeyCode.BraceLeft, Key.OemOpenBrackets }, + { KeyCode.BracketRight, Key.OemCloseBrackets }, + //{ KeyCode.?, Key.Oem7 } + //{ KeyCode.?, Key.Oem8 } + //{ KeyCode.?, Key.Oem102 } + //{ KeyCode.?, Key.ImeProcessed } + //{ KeyCode.?, Key.System } + //{ KeyCode.?, Key.OemAttn } + //{ KeyCode.?, Key.OemFinish } + //{ KeyCode.?, Key.DbeHiragana } + //{ KeyCode.?, Key.OemAuto } + //{ KeyCode.?, Key.DbeDbcsChar } + //{ KeyCode.?, Key.OemBackTab } + //{ KeyCode.?, Key.Attn } + //{ KeyCode.?, Key.DbeEnterWordRegisterMode } + //{ KeyCode.?, Key.DbeEnterImeConfigureMode } + //{ KeyCode.?, Key.EraseEof } + //{ KeyCode.MediaPlay, Key.Play }, + //{ KeyCode.?, Key.Zoom } + //{ KeyCode.?, Key.NoName } + //{ KeyCode.?, Key.DbeEnterDialogConversionMode } + //{ KeyCode.?, Key.OemClear } + //{ KeyCode.?, Key.DeadCharProcessed } + { KeyCode.Backslash, Key.OemBackslash } + }; + + internal static Key ConvertKey(KeyCode key) + { + return KeyDic.TryGetValue(key, out var result) ? result : Key.None; + } +} diff --git a/src/Tizen/Avalonia.Tizen/Platform/Interop/Strucs.cs b/src/Tizen/Avalonia.Tizen/Platform/Interop/Strucs.cs new file mode 100644 index 0000000000..0d02360f08 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Platform/Interop/Strucs.cs @@ -0,0 +1,20 @@ +using System.Runtime.InteropServices; + +namespace Avalonia.Tizen.Platform.Interop; + +struct TexturedQuadVertex +{ + public Vec2 position; +}; + +[StructLayout(LayoutKind.Sequential)] +struct Vec2 +{ + float x; + float y; + public Vec2(float xIn, float yIn) + { + x = xIn; + y = yIn; + } +} diff --git a/src/Tizen/Avalonia.Tizen/Platform/Permissions.cs b/src/Tizen/Avalonia.Tizen/Platform/Permissions.cs new file mode 100644 index 0000000000..5a540d9317 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Platform/Permissions.cs @@ -0,0 +1,110 @@ +using System.Security; +using System.Security.Permissions; +using Tizen.Applications; +using Tizen.Security; + +namespace Avalonia.Tizen.Platform; +internal class Permissions +{ + public record Privilege (string Path, bool IsRuntime); + + public static readonly Privilege InternetPrivilege = new("http://tizen.org/privilege/internet", false); + public static readonly Privilege NetworkPrivilege = new("http://tizen.org/privilege/network.get", false); + public static readonly Privilege CameraPrivilege = new("http://tizen.org/privilege/camera", false); + public static readonly Privilege ContactReadPrivilege = new("http://tizen.org/privilege/contact.read", true); + public static readonly Privilege ContactWritePrivilege = new("http://tizen.org/privilege/contact.write", true); + public static readonly Privilege LedPrivilege = new("http://tizen.org/privilege/led", false); + public static readonly Privilege AppManagerLaunchPrivilege = new("http://tizen.org/privilege/appmanager.launch", false); + public static readonly Privilege LocationPrivilege = new("http://tizen.org/privilege/location", true); + public static readonly Privilege MapServicePrivilege = new("http://tizen.org/privilege/mapservice", false); + public static readonly Privilege MediaStoragePrivilege = new("http://tizen.org/privilege/mediastorage", true); + public static readonly Privilege RecorderPrivilege = new("http://tizen.org/privilege/recorder", false); + public static readonly Privilege HapticPrivilege = new("http://tizen.org/privilege/haptic", false); + + public static readonly Privilege[] NetworkPrivileges = { InternetPrivilege, NetworkPrivilege }; + public static readonly Privilege[] MapsPrivileges = { InternetPrivilege, MapServicePrivilege, NetworkPrivilege }; + + public static Package CurrentPackage + { + get + { + var packageId = global::Tizen.Applications.Application.Current.ApplicationInfo.PackageId; + return PackageManager.GetPackage(packageId); + } + } + + public static bool IsPrivilegeDeclared(string tizenPrivilege) + { + var tizenPrivileges = tizenPrivilege; + + if (tizenPrivileges == null || !tizenPrivileges.Any()) + return false; + + var package = CurrentPackage; + + if (!package.Privileges.Contains(tizenPrivilege)) + return false; + + return true; + } + + public static void EnsureDeclared(params Privilege[] requiredPrivileges) + { + if (requiredPrivileges?.Any() != true) + return; + + foreach (var (tizenPrivilege, isRuntime) in requiredPrivileges) + { + if (!IsPrivilegeDeclared(tizenPrivilege)) + throw new SecurityException($"You need to declare the privilege: `{tizenPrivilege}` in your tizen-manifest.xml"); + } + } + + public static Task CheckPrivilegeAsync(params Privilege[] requiredPrivileges) => CheckPrivilegeAsync(requiredPrivileges, false); + public static Task RequestPrivilegeAsync(params Privilege[] requiredPrivileges) => CheckPrivilegeAsync(requiredPrivileges, true); + private static async Task CheckPrivilegeAsync(Privilege[] requiredPrivileges, bool ask) + { + var ret = global::Tizen.System.Information.TryGetValue("http://tizen.org/feature/profile", out string profile); + if (!ret || (ret && (!profile.Equals("mobile") || !profile.Equals("wearable")))) + { + return true; + } + + if (requiredPrivileges == null || !requiredPrivileges.Any()) + return true; + + EnsureDeclared(); + + var tizenPrivileges = requiredPrivileges.Where(p => p.IsRuntime); + + foreach (var (tizenPrivilege, isRuntime) in tizenPrivileges) + { + var checkResult = PrivacyPrivilegeManager.CheckPermission(tizenPrivilege); + if (checkResult == CheckResult.Ask) + { + if (ask) + { + var tcs = new TaskCompletionSource(); + PrivacyPrivilegeManager.GetResponseContext(tizenPrivilege) + .TryGetTarget(out var context); + void OnResponseFetched(object sender, RequestResponseEventArgs e) + { + tcs.TrySetResult(e.result == RequestResult.AllowForever); + } + context.ResponseFetched += OnResponseFetched; + PrivacyPrivilegeManager.RequestPermission(tizenPrivilege); + var result = await tcs.Task; + context.ResponseFetched -= OnResponseFetched; + if (result) + continue; + } + return false; + } + else if (checkResult == CheckResult.Deny) + { + return false; + } + } + return true; + } +} diff --git a/src/Tizen/Avalonia.Tizen/Platform/TizenPlatformSettings.cs b/src/Tizen/Avalonia.Tizen/Platform/TizenPlatformSettings.cs new file mode 100644 index 0000000000..066f4ac5fe --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Platform/TizenPlatformSettings.cs @@ -0,0 +1,10 @@ +using System; +using Avalonia.Platform; +using Tizen.NUI; + +namespace Avalonia.Tizen.Platform; + +internal class TizenPlatformSettings : DefaultPlatformSettings +{ + +} diff --git a/src/Tizen/Avalonia.Tizen/Stubs.cs b/src/Tizen/Avalonia.Tizen/Stubs.cs new file mode 100644 index 0000000000..e8c93de1dc --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/Stubs.cs @@ -0,0 +1,65 @@ +using Avalonia.Input; +using Avalonia.Platform; + +namespace Avalonia.Tizen; + +internal class WindowingPlatformStub : IWindowingPlatform +{ + public IWindowImpl CreateWindow() => throw new NotSupportedException(); + + public IWindowImpl CreateEmbeddableWindow() => throw new NotSupportedException(); + + public ITrayIconImpl CreateTrayIcon() => null; +} + +internal class PlatformIconLoaderStub : IPlatformIconLoader +{ + public IWindowIconImpl LoadIcon(IBitmapImpl bitmap) + { + using (var stream = new MemoryStream()) + { + bitmap.Save(stream); + return LoadIcon(stream); + } + } + + public IWindowIconImpl LoadIcon(Stream stream) + { + var ms = new MemoryStream(); + stream.CopyTo(ms); + return new IconStub(ms); + } + + public IWindowIconImpl LoadIcon(string fileName) + { + using (var file = File.Open(fileName, FileMode.Open)) + return LoadIcon(file); + } +} + +internal class IconStub : IWindowIconImpl +{ + private readonly MemoryStream _ms; + + public IconStub(MemoryStream stream) + { + _ms = stream; + } + + public void Save(Stream outputStream) + { + _ms.Position = 0; + _ms.CopyTo(outputStream); + } +} + +internal class CursorFactoryStub : ICursorFactory +{ + public ICursorImpl CreateCursor(IBitmapImpl cursor, PixelPoint hotSpot) => new CursorImplStub(); + ICursorImpl ICursorFactory.GetCursor(StandardCursorType cursorType) => new CursorImplStub(); + + private class CursorImplStub : ICursorImpl + { + public void Dispose() { } + } +} diff --git a/src/Tizen/Avalonia.Tizen/TizenApplicationExtensions.cs b/src/Tizen/Avalonia.Tizen/TizenApplicationExtensions.cs new file mode 100644 index 0000000000..72297a4fc5 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TizenApplicationExtensions.cs @@ -0,0 +1,20 @@ +namespace Avalonia.Tizen; + +/// +/// Extension to setup app builder with tizen backend +/// +public static class TizenApplicationExtensions +{ + /// + /// Use tizen builder to setup tizen sub system + /// + /// Avalonia App Builder + /// Return same builder + public static AppBuilder UseTizen(this AppBuilder builder) + { + return builder + .UseWindowingSubsystem(TizenPlatform.Initialize, "Tizen") + .UseStandardRuntimePlatformSubsystem() + .UseSkia(); + } +} diff --git a/src/Tizen/Avalonia.Tizen/TizenPlatform.cs b/src/Tizen/Avalonia.Tizen/TizenPlatform.cs new file mode 100644 index 0000000000..5085994137 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TizenPlatform.cs @@ -0,0 +1,35 @@ +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.OpenGL.Egl; +using Avalonia.Platform; +using Avalonia.Rendering; +using Avalonia.Rendering.Composition; +using Avalonia.Tizen.Platform.Input; +using Avalonia.Tizen.Platform; +using Avalonia.Threading; + +namespace Avalonia.Tizen; + +internal class TizenPlatform +{ + public static readonly TizenPlatform Instance = new(); + internal static NuiGlPlatform GlPlatform { get; set; } + internal static Compositor Compositor { get; private set; } + internal static TizenThreadingInterface ThreadingInterface { get; private set; } = new(); + + public static void Initialize() + { + AvaloniaLocator.CurrentMutable + .Bind().ToTransient() + .Bind().ToConstant(new WindowingPlatformStub()) + .Bind().ToSingleton() + .Bind().ToSingleton() + .Bind().ToConstant(ThreadingInterface) + .Bind().ToSingleton() + .Bind().ToConstant(new TizenRenderTimer()) + .Bind().ToSingleton() + .Bind().ToConstant(GlPlatform = new NuiGlPlatform()); + + Compositor = new Compositor(AvaloniaLocator.Current.GetService()); + } +} diff --git a/src/Tizen/Avalonia.Tizen/TizenRenderTimer.cs b/src/Tizen/Avalonia.Tizen/TizenRenderTimer.cs new file mode 100644 index 0000000000..7f384dca0e --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TizenRenderTimer.cs @@ -0,0 +1,44 @@ +using System.Diagnostics; +using Avalonia.Rendering; +using Tizen.Account.AccountManager; +using Tizen.System; + +namespace Avalonia.Tizen; +internal class TizenRenderTimer : IRenderTimer +{ + private readonly Stopwatch _st = Stopwatch.StartNew(); + private Timer _timer; + + public bool RunsInBackground => true; + + public event Action? Tick; + public event Action? RenderTick; + + public TizenRenderTimer() + { + _timer = new Timer(TimerTick, null, 16, 16); + Display.StateChanged += Display_StateChanged; + } + + private void TimerTick(object? state) + { + RenderTick?.Invoke(); + } + + private void Display_StateChanged(object? sender, DisplayStateChangedEventArgs e) + { + if (e.State == DisplayState.Off) + { + _timer.Change(Timeout.Infinite, Timeout.Infinite); + } + else + { + _timer.Change(16, 16); + } + } + + internal void ManualTick() + { + Tick?.Invoke(_st.Elapsed); + } +} diff --git a/src/Tizen/Avalonia.Tizen/TizenStorageProvider.cs b/src/Tizen/Avalonia.Tizen/TizenStorageProvider.cs new file mode 100644 index 0000000000..444d896fc8 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TizenStorageProvider.cs @@ -0,0 +1,152 @@ +using System.Security; +using Avalonia.Platform.Storage; +using Avalonia.Platform.Storage.FileIO; +using Avalonia.Tizen.Platform; +using Tizen.Applications; + +namespace Avalonia.Tizen; +internal class TizenStorageProvider : IStorageProvider +{ + public bool CanOpen => true; + + public bool CanSave => false; + + public bool CanPickFolder => false; + + public Task OpenFileBookmarkAsync(string bookmark) + { + return Task.FromException( + new PlatformNotSupportedException("Bookmark is not supported by Tizen")); + } + + private static async Task CheckPermission() + { + Permissions.EnsureDeclared(Permissions.AppManagerLaunchPrivilege); + if (await Permissions.RequestPrivilegeAsync(Permissions.MediaStoragePrivilege) == false) + { + throw new SecurityException("Application doesn't have storage permission."); + } + } + + public async Task> OpenFilePickerAsync(FilePickerOpenOptions options) + { + await CheckPermission(); + + var tcs = new TaskCompletionSource>(); + +#pragma warning disable CS8603 // Possible null reference return. + var fileType = options.FileTypeFilter? + .Where(w => w.MimeTypes != null) + .SelectMany(s => s.MimeTypes); +#pragma warning restore CS8603 // Possible null reference return. + + var appControl = new AppControl + { + Operation = AppControlOperations.Pick, + Mime = fileType?.Any() == true + ? fileType.Aggregate((o, n) => o + ";" + n) + : "*/*" + }; + appControl.ExtraData.Add(AppControlData.SectionMode, options.AllowMultiple ? "multiple" : "single"); + if (options.SuggestedStartLocation?.Path is { } startupPath) + appControl.ExtraData.Add(AppControlData.Path, startupPath.ToString()); + appControl.LaunchMode = AppControlLaunchMode.Single; + + var fileResults = new List(); + + AppControl.SendLaunchRequest(appControl, (_, reply, result) => + { + if (result == AppControlReplyResult.Succeeded) + { + if (reply.ExtraData.Count() > 0) + { + var selectedFiles = reply.ExtraData.Get>(AppControlData.Selected).ToList(); + fileResults.AddRange(selectedFiles.Select(f => new BclStorageFile(new(f)))); + } + } + + tcs.TrySetResult(fileResults); + }); + + return await tcs.Task; + } + + public Task> OpenFolderPickerAsync(FolderPickerOpenOptions options) + { + return Task.FromException>( + new PlatformNotSupportedException("Open folder is not supported by Tizen")); + } + + public Task OpenFolderBookmarkAsync(string bookmark) + { + return Task.FromException( + new PlatformNotSupportedException("Open folder is not supported by Tize")); + } + + public Task SaveFilePickerAsync(FilePickerSaveOptions options) + { + return Task.FromException( + new PlatformNotSupportedException("Save file picker is not supported by Tizen")); + } + + public async Task TryGetFileFromPathAsync(Uri filePath) + { + await CheckPermission(); + + if (filePath is not { IsAbsoluteUri: true, Scheme: "file" }) + { + throw new ArgumentException("File path is expected to be an absolute link with \"file\" scheme."); + } + + var path = Path.Combine(global::Tizen.Applications.Application.Current.DirectoryInfo.Resource, filePath.AbsolutePath); + var file = new FileInfo(path); + if (!file.Exists) + { + return null; + } + + return new BclStorageFile(file); + } + + public async Task TryGetFolderFromPathAsync(Uri folderPath) + { + if (folderPath is null) + { + throw new ArgumentNullException(nameof(folderPath)); + } + + await CheckPermission(); + + if (folderPath is not { IsAbsoluteUri: true, Scheme: "file" }) + { + throw new ArgumentException("File path is expected to be an absolute link with \"file\" scheme."); + } + + var path = Path.Combine(global::Tizen.Applications.Application.Current.DirectoryInfo.Resource, folderPath.AbsolutePath); + var directory = new System.IO.DirectoryInfo(path); + if (!directory.Exists) + return null; + + return new BclStorageFolder(directory); + } + + public Task TryGetWellKnownFolderAsync(WellKnownFolder wellKnownFolder) + { + var folder = wellKnownFolder switch + { + WellKnownFolder.Desktop => null, + WellKnownFolder.Documents => global::Tizen.Applications.Application.Current.DirectoryInfo.Data, + WellKnownFolder.Downloads => global::Tizen.Applications.Application.Current.DirectoryInfo.SharedData, + WellKnownFolder.Music => null, + WellKnownFolder.Pictures => null, + WellKnownFolder.Videos => null, + _ => throw new ArgumentOutOfRangeException(nameof(wellKnownFolder), wellKnownFolder, null), + }; + + if (folder == null) + return Task.FromResult(null); + + var storageFolder = new BclStorageFolder(new System.IO.DirectoryInfo(folder)); + return Task.FromResult(storageFolder); + } +} diff --git a/src/Tizen/Avalonia.Tizen/TizenThreadingInterface.cs b/src/Tizen/Avalonia.Tizen/TizenThreadingInterface.cs new file mode 100644 index 0000000000..ce62abdccc --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TizenThreadingInterface.cs @@ -0,0 +1,50 @@ +using Avalonia.Platform; +using Avalonia.Threading; + +namespace Avalonia.Tizen; +internal class TizenThreadingInterface : IPlatformThreadingInterface +{ + internal event Action? TickExecuted; + private bool _signaled; + +#pragma warning disable CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + internal static SynchronizationContext MainloopContext { get; set; } +#pragma warning restore CS8618 // Non-nullable field must contain a non-null value when exiting constructor. Consider declaring as nullable. + + public IDisposable StartTimer(DispatcherPriority priority, TimeSpan interval, Action tick) + { + return new Timer(_ => + { + EnsureInvokeOnMainThread(tick); + }, null, interval, interval); + } + + private void EnsureInvokeOnMainThread(Action action) + { + if (SynchronizationContext.Current != null) + action(); + else + MainloopContext.Post(static arg => ((Action)arg!).Invoke(), action); + } + + public void Signal(DispatcherPriority prio) + { + if (_signaled) + return; + + _signaled = true; + var interval = TimeSpan.FromMilliseconds(1); + + IDisposable? disp = null; + disp = new Timer(_ => + { + _signaled = false; + disp?.Dispose(); + + EnsureInvokeOnMainThread(() => Signaled?.Invoke(prio)); + }, null, interval, interval); + } + + public bool CurrentThreadIsLoopThread => SynchronizationContext.Current != null; + public event Action? Signaled; +} diff --git a/src/Tizen/Avalonia.Tizen/TopLevelImpl.cs b/src/Tizen/Avalonia.Tizen/TopLevelImpl.cs new file mode 100644 index 0000000000..ae902353f4 --- /dev/null +++ b/src/Tizen/Avalonia.Tizen/TopLevelImpl.cs @@ -0,0 +1,117 @@ +using Avalonia.Controls; +using Avalonia.Controls.Platform; +using Avalonia.Input; +using Avalonia.Input.Platform; +using Avalonia.Input.Raw; +using Avalonia.Input.TextInput; +using Avalonia.Platform; +using Avalonia.Platform.Storage; +using Avalonia.Rendering.Composition; +using Avalonia.Tizen.Platform.Input; + +namespace Avalonia.Tizen; + +internal class TopLevelImpl : ITopLevelImpl +{ + private readonly ITizenView _view; + private readonly NuiClipboardImpl _clipboard; + private readonly IStorageProvider _storageProvider; + + public TopLevelImpl(ITizenView view, IEnumerable surfaces) + { + _view = view; + Surfaces = surfaces; + + _storageProvider = new TizenStorageProvider(); + _clipboard = new NuiClipboardImpl(); + } + + public Size ClientSize => _view.ClientSize; + + public Size? FrameSize => null; + + public double RenderScaling => 1; + + public IEnumerable Surfaces { get; set; } + + public Action? Input { get; set; } + public Action? Paint { get; set; } + public Action? Resized { get; set; } + public Action? ScalingChanged { get; set; } + public Action? TransparencyLevelChanged { get; set; } + + public Compositor Compositor => TizenPlatform.Compositor; + + public Action? Closed { get; set; } + public Action? LostFocus { get; set; } + + public WindowTransparencyLevel TransparencyLevel { get; set; } + + public AcrylicPlatformCompensationLevels AcrylicCompensationLevels { get; } = new AcrylicPlatformCompensationLevels(); + public IPopupImpl? CreatePopup() + { + return null; + } + + public void Dispose() + { + // + } + + public Point PointToClient(PixelPoint point) => new Point(point.X, point.Y); + + public PixelPoint PointToScreen(Point point) => new PixelPoint((int)point.X, (int)point.Y); + + public void SetCursor(ICursorImpl? cursor) + { + // + } + + public void SetFrameThemeVariant(PlatformThemeVariant themeVariant) + { + // + } + + public void SetInputRoot(IInputRoot inputRoot) + { + _view.InputRoot = inputRoot; + } + + public void SetTransparencyLevelHint(IReadOnlyList transparencyLevels) + { + // + } + + public object? TryGetFeature(Type featureType) + { + if (featureType == typeof(IStorageProvider)) + { + return _storageProvider; + } + + if (featureType == typeof(ITextInputMethodImpl)) + { + return _view; + } + + if (featureType == typeof(INativeControlHostImpl)) + { + return _view.NativeControlHost; + } + + if (featureType == typeof(IClipboard)) + { + return _clipboard; + } + + return null; + } + + internal void TextInput(string text) + { + if (Input == null) return; + var args = new RawTextInputEventArgs(TizenKeyboardDevice.Instance, (ulong)DateTime.Now.Ticks, _view.InputRoot, text); + + Input(args); + } +}