diff --git a/docs/controls/keybinding.md b/docs/controls/keybinding.md new file mode 100644 index 00000000..f94d74b9 --- /dev/null +++ b/docs/controls/keybinding.md @@ -0,0 +1,37 @@ +# KeyBinding + +> _Note_: You can check the Avalonia docs for [KeyBinding](https://docs.avaloniaui.net/docs/concepts/input/binding-key-and-mouse) if you need more information. +> +> For Avalonia.FuncUI's DSL properties you can check [KeyBinding.fs](https://github.com/AvaloniaCommunity/Avalonia.FuncUI/blob/master/src/Avalonia.FuncUI.DSL/KeyBinding.fs). + +A key binding allows you to dispatch a message (or trigger some other action) when the user presses a key on the keyboard. For example: + +```fsharp +KeyBinding.create [ + KeyBinding.key Key.F11 + KeyBinding.execute (fun _ -> + FullScreen true + |> dispatch) +] +``` + +### Usage + +**Create a Key Binding** + +```fsharp +KeyBinding.create [] +``` + +**Bind to a Specific Key** + +```fsharp +KeyBinding.key Key.Escape +``` + +**Trigger a Message** + +```fsharp +KeyBinding.execute (fun _ -> + dispatch MyMessage) +``` diff --git a/docs/controls/window.md b/docs/controls/window.md new file mode 100644 index 00000000..f06a7e86 --- /dev/null +++ b/docs/controls/window.md @@ -0,0 +1,64 @@ +# Window + +> _Note_: You can check the Avalonia docs for the [Window](https://docs.avaloniaui.net/docs/controls/window) and [Window API](http://reference.avaloniaui.net/api/Avalonia.Controls/Window/) if you need more information. +> +> For Avalonia.FuncUI's DSL properties you can check [Window.fs](https://github.com/AvaloniaCommunity/Avalonia.FuncUI/blob/master/src/Avalonia.FuncUI.DSL/Window.fs). + +Window corresponds to your application's host window, and allows you to set top-level properties like window title and key bindings. + +### Usage + +**Create a Window** + +```fsharp +Window.create [] +``` + +**Set Window Title** + +```fsharp +Window.title "My Application" +``` + +**Set Window Icon** + +```fsharp +let icon = + Path.Combine("Assets", "Icons", "icon.ico") + |> WindowIcon + +Window.icon icon +``` + +**Size Window to Content** + +```fsharp +Window.sizeToContent SizeToContent.WidthAndHeight +``` + +**Switch to Full-Screen Mode** + +```fsharp +Window.windowState ( + if state.FullScreen then WindowState.FullScreen + else WindowState.Normal) +``` + +**Create Key Bindings** + +```fsharp +Window.keyBindings [ + KeyBinding.create [ + KeyBinding.key Key.F11 + KeyBinding.execute (fun _ -> + FullScreen true + |> dispatch) + ] + KeyBinding.create [ + KeyBinding.key Key.Escape + KeyBinding.execute (fun _ -> + FullScreen false + |> dispatch) + ] +] +``` diff --git a/src/Avalonia.FuncUI.Elmish/Library.fs b/src/Avalonia.FuncUI.Elmish/Library.fs index 3d6ed35c..398f0d2f 100644 --- a/src/Avalonia.FuncUI.Elmish/Library.fs +++ b/src/Avalonia.FuncUI.Elmish/Library.fs @@ -1,6 +1,8 @@ namespace Avalonia.FuncUI.Elmish open Elmish +open Avalonia.Controls +open Avalonia.FuncUI.DSL open Avalonia.FuncUI.Hosts open Avalonia.FuncUI.Types open Avalonia.Threading @@ -15,8 +17,14 @@ module Program = if stateDiffers then stateRef.Value <- Some state - let view = ((Program.view program) state dispatch) - host.Update (Some (view :> IView)) + let userView = ((Program.view program) state dispatch) :> IView + + match userView with + | :? IView as windowView -> + host.Update(Some windowView) + | controlView -> + let wrappedView = Window.create [ Window.child controlView ] + host.Update(Some wrappedView) program |> Program.withSetState setState diff --git a/src/Avalonia.FuncUI.sln b/src/Avalonia.FuncUI.sln index 5550061d..a22f5a42 100644 --- a/src/Avalonia.FuncUI.sln +++ b/src/Avalonia.FuncUI.sln @@ -49,9 +49,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solutio ProjectSection(SolutionItems) = preProject Directory.Build.props = Directory.Build.props NuGet.config = NuGet.config - ..\README.md = ..\README.md ..\.github\workflows\publish.yml = ..\.github\workflows\publish.yml ..\.github\workflows\pull-requests.yml = ..\.github\workflows\pull-requests.yml + ..\README.md = ..\README.md EndProjectSection EndProject Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "Examples.InlineText", "Examples\Component Examples\Examples.InlineText\Examples.InlineText.fsproj", "{B8D8C84B-05AD-475B-BE81-A30544CE0149}" @@ -90,6 +90,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Examples.Elmish.Tetris", "E EndProject Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Examples.SettingFocus", "Examples\Examples.SettingFocus\Examples.SettingFocus.fsproj", "{2C7F4542-65CE-4EC6-9876-C1C2215EE006}" EndProject +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Examples.Elmish.HelloWorld", "Examples\Elmish Examples\Examples.Elmish.HelloWorld\Examples.Elmish.HelloWorld.fsproj", "{72F1A6FA-492E-0E7A-08CF-93C5737B11BB}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -212,6 +214,10 @@ Global {2C7F4542-65CE-4EC6-9876-C1C2215EE006}.Debug|Any CPU.Build.0 = Debug|Any CPU {2C7F4542-65CE-4EC6-9876-C1C2215EE006}.Release|Any CPU.ActiveCfg = Release|Any CPU {2C7F4542-65CE-4EC6-9876-C1C2215EE006}.Release|Any CPU.Build.0 = Release|Any CPU + {72F1A6FA-492E-0E7A-08CF-93C5737B11BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {72F1A6FA-492E-0E7A-08CF-93C5737B11BB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {72F1A6FA-492E-0E7A-08CF-93C5737B11BB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {72F1A6FA-492E-0E7A-08CF-93C5737B11BB}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -249,6 +255,7 @@ Global {6D2C62FC-5634-4997-AF1F-2E8A5D27E117} = {84811DB3-C276-4F0D-B3BA-78B88E2C6EF0} {EC63B886-E809-4B74-B533-BFF3D60017C9} = {6D2C62FC-5634-4997-AF1F-2E8A5D27E117} {2C7F4542-65CE-4EC6-9876-C1C2215EE006} = {F50826CE-D9BC-45CF-A110-C42225B75AD3} + {72F1A6FA-492E-0E7A-08CF-93C5737B11BB} = {F6F4AAF7-2BDA-4D2F-B78D-F6D8A03F660E} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4630E817-6780-4C98-9379-EA3B45224339} diff --git a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj index fdaf4b7e..b85b32af 100644 --- a/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj +++ b/src/Avalonia.FuncUI/Avalonia.FuncUI.fsproj @@ -181,6 +181,8 @@ + + diff --git a/src/Avalonia.FuncUI/DSL/KeyBinding.fs b/src/Avalonia.FuncUI/DSL/KeyBinding.fs new file mode 100644 index 00000000..2d31e47a --- /dev/null +++ b/src/Avalonia.FuncUI/DSL/KeyBinding.fs @@ -0,0 +1,54 @@ +namespace Avalonia.FuncUI.DSL + +open System +open System.Windows.Input + +open Avalonia.Input +open Avalonia.FuncUI.DSL +open Avalonia.FuncUI.Types +open Avalonia.FuncUI.Builder + +[] +module KeyBinding = + + let create (attrs: IAttr list) : IView = + ViewBuilder.Create(attrs) + + let private toCommand action = + let canExecuteChanged = Event() + { + new ICommand with + member _.CanExecute(_) = true + member _.Execute(parameter) = action parameter + [] + member _.CanExecuteChanged = + canExecuteChanged.Publish + } + + type KeyBinding with + + static member gesture<'t when 't :> KeyBinding> (value: KeyGesture) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty(KeyBinding.GestureProperty, value, ValueNone) + + static member key<'t when 't :> KeyBinding> (value: Key) : IAttr<'t> = + KeyBinding.gesture (KeyGesture(value)) + + static member command<'t when 't :> KeyBinding> (value: ICommand) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty(KeyBinding.CommandProperty, value, ValueNone) + + static member execute<'t when 't :> KeyBinding> (value : obj -> unit) : IAttr<'t> = + KeyBinding.command (toCommand value) + + static member commandParameter<'t when 't :> KeyBinding> (value: obj) : IAttr<'t> = + AttrBuilder<'t>.CreateProperty(KeyBinding.CommandParameterProperty, value, ValueNone) + + type InputElement with + + static member keyBindings<'t when 't :> InputElement> (bindings: IView list) : IAttr<'t> = + let getter: 't -> obj = fun control -> control.KeyBindings :> obj + + AttrBuilder<'t>.CreateContentMultiple( + "KeyBindings", + ValueSome getter, + ValueNone, + bindings |> List.map (fun v -> v :> IView)) diff --git a/src/Avalonia.FuncUI/DSL/Window.fs b/src/Avalonia.FuncUI/DSL/Window.fs new file mode 100644 index 00000000..8201601f --- /dev/null +++ b/src/Avalonia.FuncUI/DSL/Window.fs @@ -0,0 +1,64 @@ +namespace Avalonia.FuncUI.DSL + +open Avalonia.Controls +open Avalonia.FuncUI.Types +open Avalonia.FuncUI.Builder + +[] +module Window = + + let create (attrs: IAttr list) : IView = + ViewBuilder.Create(attrs) + + type Window with + + static member canResize<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.CanResizeProperty, value, ValueNone) + + static member child(value: IView) = + AttrBuilder.CreateContentSingle(Window.ContentProperty, Some value) + + static member closingBehavior<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ClosingBehaviorProperty, value, ValueNone) + + static member extendClientAreaChromeHints<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ExtendClientAreaChromeHintsProperty, value, ValueNone) + + static member extendClientAreaTitleBarHeightHint<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ExtendClientAreaTitleBarHeightHintProperty, value, ValueNone) + + static member extendClientAreaToDecorationsHint<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ExtendClientAreaToDecorationsHintProperty, value, ValueNone) + + static member isExtendedIntoWindowDecorations<'t when 't :> Window>(func, ?subPatchOptions) = + AttrBuilder<'t>.CreateSubscription(Window.IsExtendedIntoWindowDecorationsProperty, func, ?subPatchOptions = subPatchOptions) + + static member icon<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.IconProperty, value, ValueNone) + + static member offScreenMargin<'t when 't :> Window>(func, ?subPatchOptions) = + AttrBuilder<'t>.CreateSubscription(Window.OffScreenMarginProperty, func, ?subPatchOptions = subPatchOptions) + + static member showActivated<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ShowActivatedProperty, value, ValueNone) + + static member showInTaskbar<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.ShowInTaskbarProperty, value, ValueNone) + + static member sizeToContent<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.SizeToContentProperty, value, ValueNone) + + static member systemDecorations<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.SystemDecorationsProperty, value, ValueNone) + + static member title<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.TitleProperty, value, ValueNone) + + static member windowDecorationMargin<'t when 't :> Window>(func, ?subPatchOptions) = + AttrBuilder<'t>.CreateSubscription(Window.WindowDecorationMarginProperty, func, ?subPatchOptions = subPatchOptions) + + static member windowStartupLocation<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.WindowStartupLocationProperty, value, ValueNone) + + static member windowState<'t when 't :> Window>(value) = + AttrBuilder<'t>.CreateProperty(Window.WindowStateProperty, value, ValueNone) diff --git a/src/Avalonia.FuncUI/VirtualDom/VirtualDom.fs b/src/Avalonia.FuncUI/VirtualDom/VirtualDom.fs index 17f08a94..25d3d019 100644 --- a/src/Avalonia.FuncUI/VirtualDom/VirtualDom.fs +++ b/src/Avalonia.FuncUI/VirtualDom/VirtualDom.fs @@ -17,14 +17,6 @@ module rec VirtualDom = Patcher.patch(root, delta) let updateRoot (host: ContentControl, last: IView option, next: IView option) = - let root : Control voption = - if host.Content <> null then - match host.Content with - | :? Control as control -> ValueSome control - | _ -> ValueNone - else - ValueNone - let delta : ViewDelta voption = match last with | Some last -> @@ -35,21 +27,33 @@ module rec VirtualDom = match next with | Some next -> ViewDelta.From next |> ValueSome | None -> ValueNone + + match host, delta with + | :? Window as windowHost, ValueSome viewDelta when viewDelta.ViewType.IsSubclassOf(typeof) || viewDelta.ViewType = typeof -> + Patcher.patch(windowHost, viewDelta) + | _ -> + let root : Control voption = + if host.Content <> null then + match host.Content with + | :? Control as control -> ValueSome control + | _ -> ValueNone + else + ValueNone - match root with - | ValueSome control -> - match delta with - | ValueSome delta -> - match control.GetType () = delta.ViewType && not delta.KeyDidChange with - | true -> Patcher.patch (control, delta) - | false -> host.Content <- Patcher.create delta - | ValueNone -> - host.Content <- null + match root with + | ValueSome control -> + match delta with + | ValueSome delta -> + match control.GetType () = delta.ViewType && not delta.KeyDidChange with + | true -> Patcher.patch (control, delta) + | false -> host.Content <- Patcher.create delta + | ValueNone -> + host.Content <- null - | ValueNone -> - match delta with - | ValueSome delta -> host.Content <- Patcher.create delta - | ValueNone -> host.Content <- null + | ValueNone -> + match delta with + | ValueSome delta -> host.Content <- Patcher.create delta + | ValueNone -> host.Content <- null // TODO: share code with updateRoot let internal updateBorderRoot (host: Border, last: IView option, next: IView option) = diff --git a/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Assets/Icons/icon.ico b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Assets/Icons/icon.ico new file mode 100644 index 00000000..9fd305d0 Binary files /dev/null and b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Assets/Icons/icon.ico differ diff --git a/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Examples.Elmish.HelloWorld.fsproj b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Examples.Elmish.HelloWorld.fsproj new file mode 100644 index 00000000..edd161f0 --- /dev/null +++ b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Examples.Elmish.HelloWorld.fsproj @@ -0,0 +1,27 @@ + + + + Exe + net8.0 + Examples.CounterApp + + + + + PreserveNewest + + + + + + + + + + + + + + + + diff --git a/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/HelloWorld.fs b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/HelloWorld.fs new file mode 100644 index 00000000..e08e6ed9 --- /dev/null +++ b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/HelloWorld.fs @@ -0,0 +1,77 @@ +namespace Examples.CounterApp + +open System.IO + +open Avalonia.Controls +open Avalonia.FuncUI.DSL +open Avalonia.Input + +module HelloWorld = + + type State = + { + Name : string + FullScreen : bool + } + let init() = + { + Name = "World" + FullScreen = false + } + + type Msg = + | NameChanged of string + | FullScreen of bool + + let update (msg: Msg) (state: State) : State = + match msg with + | NameChanged name -> + { state with Name = name } + | FullScreen fullScreen -> + { state with FullScreen = fullScreen } + + let icon = + Path.Combine("Assets", "Icons", "icon.ico") + |> WindowIcon + + let view (state: State) (dispatch) = + Window.create [ + Window.title $"Hello {state.Name}!" + Window.icon icon + Window.sizeToContent SizeToContent.WidthAndHeight + Window.windowState ( + if state.FullScreen then WindowState.FullScreen + else WindowState.Normal) + Window.child ( + StackPanel.create [ + StackPanel.margin 10 + StackPanel.spacing 10 + StackPanel.children [ + TextBlock.create [ + TextBlock.text "Type your name here to change the window title:" + ] + TextBox.create [ + TextBox.text state.Name + TextBox.onTextChanged (NameChanged >> dispatch) + ] + TextBlock.create [ + TextBlock.text "Or use F11 key to enter full screen mode and Esc key to exit." + ] + ] + ] + ) + Window.keyBindings [ + KeyBinding.create [ + KeyBinding.key Key.F11 + KeyBinding.execute (fun _ -> + FullScreen true + |> dispatch) + ] + KeyBinding.create [ + KeyBinding.key Key.Escape + KeyBinding.execute (fun _ -> + FullScreen false + |> dispatch) + ] + ] + ] diff --git a/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Program.fs b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Program.fs new file mode 100644 index 00000000..b194fbae --- /dev/null +++ b/src/Examples/Elmish Examples/Examples.Elmish.HelloWorld/Program.fs @@ -0,0 +1,41 @@ +namespace Examples.CounterApp + +open Avalonia +open Avalonia.Controls.ApplicationLifetimes +open Avalonia.FuncUI.Hosts +open Avalonia.FuncUI.Elmish +open Avalonia.Themes.Fluent + +open Elmish + +type MainWindow() as this = + inherit HostWindow() + do + Elmish.Program.mkSimple HelloWorld.init HelloWorld.update HelloWorld.view + |> Program.withHost this + |> Program.withConsoleTrace + |> Program.run + +type App() = + inherit Application() + + override this.Initialize() = + this.Styles.Add (FluentTheme()) + this.RequestedThemeVariant <- Styling.ThemeVariant.Dark + + override this.OnFrameworkInitializationCompleted() = + match this.ApplicationLifetime with + | :? IClassicDesktopStyleApplicationLifetime as desktopLifetime -> + let mainWindow = MainWindow() + desktopLifetime.MainWindow <- mainWindow + | _ -> () + +module Program = + + [] + let main(args: string[]) = + AppBuilder + .Configure() + .UsePlatformDetect() + .UseSkia() + .StartWithClassicDesktopLifetime(args)