elmish / Elmish.WPF

Static WPF views for elmish programs
Other
428 stars 69 forks source link

How to open the WPF window a second time #210

Open ScottHutchinson opened 4 years ago

ScottHutchinson commented 4 years ago

In my application, another project calls the LoadWindow function shown below. But after the user closes the MsgTypeFiltersWindow window and attempts to open it again by calling the LoadWindow function again, I get 'System.InvalidOperationException' in PresentationFramework.dll The Application object is being shut down..

How can I write the LoadWindow function so it can be called over and over again?

Thanks

module PublicAPI =
    open NG_DART_WPF

    let LoadWindow (msgTypeID: int) (msgTypeName: string) (parentStructName: string) =
      Program.mkSimpleWpf App.init App.update App.rootBindings
      |> Program.withConsoleTrace
      |> Program.runWindowWithConfig
        { ElmConfig.Default with LogConsole = true; Measure = true }
        (MsgTypeFiltersWindow())
TysonMN commented 4 years ago

Can you share a link to a branch that exhibits this problem?

cmeeren commented 4 years ago

Haven't looked closely at this, but Program.run... is only ever intended to be run once in an app. Use the subModelWin bindings to control multiple windows.

ScottHutchinson commented 4 years ago

https://github.com/ScottHutchinson/MyExistingMFCApp

Somehow this project does not reproduce the same exception as my production application, which I cannot share with you. But it still crashes when attempting to show the window a second time.

Run the MyExistingMFCApp startup project, which will automatically open the MsgTypeFiltersWindow window. Close that window and then choose File...New to open that window again: Exception thrown at 0x769D4192 (KernelBase.dll) in MyExistingMFCApp.exe: 0xE0434352 (parameters: 0x80131509, 0x00000000, 0x00000000, 0x00000000, 0x719D0000). Unhandled exception at 0x769D4192 (KernelBase.dll) in MyExistingMFCApp.exe: 0xE0434352 (parameters: 0x80131509, 0x00000000, 0x00000000, 0x00000000, 0x719D0000).

ScottHutchinson commented 4 years ago

subModelWin

Understand, I have only one window to display, but the user can open and close it multiple times with different arguments each time that will be passed to the init function. And there will only ever be one instance of the window open at any time. Essentially, it is a model dialog window.

Maybe subModelWin is the only way to accomplish this, but it seems a bit complicated.

ScottHutchinson commented 4 years ago

Also, I'm not thrilled with having the window state in the model like this: https://github.com/elmish/Elmish.WPF/blob/d6aa789806669a8a3735b9205351b3b5a7cb2255/src/Samples/NewWindow/Program.fs#L22

ScottHutchinson commented 4 years ago

It seems like maybe we need another function like Program.runWindowWithConfig that just closes the window instead of the application. Or a parameter that changes its behavior like that.

TysonMN commented 4 years ago

https://github.com/ScottHutchinson/MyExistingMFCApp ... Run the MyExistingMFCApp startup project...

This is a C++ project, right? Is C++ necessary to reproduce the issue you are facing?

ScottHutchinson commented 4 years ago

I think C++ has nothing to do with it. But that is the context in which I want to show a WPF window as a dialog. And I'm still trying to find the best way to do that. I need to be able to call the ShowDialog again after the user closes the dialog window. Maybe I need to use the SubModelWin combined with the SubModelSeq, and I don't know if that has been done before, or if it's even possible. I don't think the NewWindow sample fits my use case very well.

TysonMN commented 4 years ago

Quoting from https://github.com/elmish/Elmish.WPF/issues/211#issue-611266584

This might be related to Issue #210.

Indeed. Issue #211 seems easier to me right now. I suggest we resolve that issue first and then reconsider this one.

ScottHutchinson commented 4 years ago

I'm finding it difficult to adapt the NewWindow sample to my use case, because each time the new window (modal dialog) is displayed, I need it to call the init function with parameters to initialize the model for a tree view and other controls in that dialog window. But in that sample, the init function is called only for the main window. The SubModelSeq sample works well for my use case, but only if the user never shows the window a second time, which is definitely not good enough.

ScottHutchinson commented 4 years ago

I wrote this function, but it doesn't solve the problem mentioned above. The App.window is just a new Window, which will always remain hidden.

    let InitializeWpfApplication () =
        if isNull Application.Current then
            Application () |> ignore
            Application.Current.MainWindow <- App.window

        let init = App.init 0 "" "" measureElapsedTime
        Program.mkSimpleWpf init App.update App.rootBindings
        |> Program.startElmishLoop ElmConfig.Default App.window
        (* Run without showing the main window, which is not needed
           since the user will open a new dialog window by clicking
           in an existing C++ MFC window 
           (in other apps, it could be an existing C# WPF window).
        *)
        Application.Current.Run App.window
ScottHutchinson commented 4 years ago

Hmmm...Maybe the simplest solution would be to just call Application.Current.Shutdown() after the user closes the dialog. Then it would just start fresh again the next time the Elmish.WPF.Program is started. If that's possible, then it should work for my use case, because I have only the one WPF window. Actually, it seems like it would work in the more general case where only one WPF window is needed at a time.

EDIT: No, that didn't help. Maybe instead I could start the Elmish.WPF Application in its own AppDomain like this example: [Running multiple WPF applications in the same process using AppDomains] (https://eprystupa.wordpress.com/2008/07/31/running-multiple-wpf-applications-in-the-same-process-using-appdomains/). Not sure about .NET Core though.

ScottHutchinson commented 4 years ago

I might need to pass in an Application object to Elmish.WPF, so I can control its lifespan and/or AppDomain.

cmeeren commented 4 years ago

I don't really understand; AFAIK Application is singleton, and instantiated only once for the whole AppDomain, when your application starts. Is this different in C++?

In any case, I think there are good Elmish-centric ways to achieve the behaviour you need, but I'm afraid I don't have the capacity to look into it now. In short, if you use Elmish.WPF for the whole app, then it shouldn't be a problem keeping a list of Elmish.WPF window states (or your preferred domain proxy) in the main model, and initializing these models however you want in the update function when it receives a message indicating that a new window should be opened.

ScottHutchinson commented 4 years ago

Is there a way to trigger an update by dispatching a message in code? Instead of binding to a WPF button, I just want the code to do it directly. Thanks

cmeeren commented 4 years ago

You can use commands/subscriptions for this. See this section in the tutorial.

TysonMN commented 4 years ago

One way to access the dispatcher is via a subscription. Here is one place in the samples where this is done. https://github.com/elmish/Elmish.WPF/blob/4452a1d2e69df377b1e9e4e72f0d6293f56c6676/src/Samples/SubModel/Program.fs#L146

ScottHutchinson commented 4 years ago

Thanks. I just found that. Also, this might be more direct: Cmd.OfFunc.result.

ScottHutchinson commented 4 years ago

I still can't figure out how to trigger an update using Cmd.OfFunc.result or Cmd.OfMsg. I ran the line below, but it did not trigger the ShowDialog case in my update function. What am I missing?

Cmd.OfFunc.result (App.ShowDialog (msgTypeID, msgTypeName, parentStructName)) |> ignore
ScottHutchinson commented 4 years ago

Trying to use subModelWin like below, but I'm stuck on how to get the msgTypeID, msgTypeName, parentStructName arguments to initialize the model. So now I'm going to try the obsolete showWindow function (or a variation of that) instead, but will probably still get stuck on how to get Cmd.OfFunc.result to work. EDIT: Maybe the best way is to bind the ShowDialog message to the dialog window's Activated event (and make the msgTypeID, msgTypeName, parentStructName arguments public members of the MsgTypeFiltersWindow).

    let bindings () : Binding<Model, Msg> list = [
        "Dialog" |> Binding.subModelWin(
            (fun m -> m.WinState), fst, id,
            rootBindings,
            (fun m dispatch -> 
                dispatch (ShowDialog (msgTypeID, msgTypeName, parentStructName))
                MsgTypeFiltersWindow(Owner = Application.Current.MainWindow)
            ),
            onCloseRequested = CloseDialog,
            isModal = true
        )
    ]
ScottHutchinson commented 4 years ago

EDIT: I think this is working. EDIT2: Sort of. Unfortunately, the Program.runWindowWithConfig call is blocking, so nothing happens until the user closes the main window. That could be a show stopper. And even if I get past that issue, I still need to figure out how to trigger the Binding.subModelWin binding without binding it to a button command.

    type DialogOpeningEventArgs = {
        MsgTypeID: int
        MsgTypeName: string
        ParentStructName: string
    }

    let dialogOpening = new Event<DialogOpeningEventArgs>()
    let raiseDialogOpening (args : DialogOpeningEventArgs) = dialogOpening.Trigger(args)
    let DialogOpening = dialogOpening.Publish
    let dialogOpeningSubscriber dispatch =
        DialogOpening.Add (fun args ->
            dispatch (ShowDialog (args.MsgTypeID, args.MsgTypeName, args.ParentStructName))
        )
...
            |> Program.withSubscription (fun _ -> Cmd.ofSub App.dialogOpeningSubscriber)
            |> Program.runWindowWithConfig...
...
        App.raiseDialogOpening { MsgTypeID = msgTypeID; MsgTypeName = msgTypeName; ParentStructName = parentStructName}
cmeeren commented 4 years ago

@ScottHutchinson, it seems you have some misconceptions regarding how the Elm architecture works.

I still can't figure out how to trigger an update using Cmd.OfFunc.result or Cmd.OfMsg. I ran the line below, but it did not trigger the ShowDialog case in my update function. What am I missing?

Cmd.OfFunc.result (App.ShowDialog (msgTypeID, msgTypeName, parentStructName)) |> ignore

This just creates a command; it does not execute it (which is done by the Elmish update loop). In order to dispatch messages in code, you need to set up a subscription. I think there is a sample that does this. You can use Program.withSubscription, or have the init function return both the model and a Cmd.

If you haven't already, I highly recommend you read the first parts of the Elmish.WPF tutorial, which explains the basics of the Elm architecture concepts. 🙂

And again: Program.runWindowWithConfig is only intended to be run once for an entire app, at the entry point of the app. It should not be used when opening dialogs or new windows. If using Elmish.WPF, that should be handled by the subModelWin binding.

ScottHutchinson commented 4 years ago

I have read all of your excellent tutorial, yet I still struggle with this use case. And I am trying everything you are saying to do, but still failing. If you read my previous post again, you'll see that am trying to do as you say.

cmeeren commented 4 years ago

Not to worry. If you can explain in very simple terms the high-level functionality you are trying to accomplish, I may be able to create a sample that demonstrates how to do it. (The sample will use Elmish.WPF for the whole app and be in F# – if that should not work for your use-case, then I'm not sure Elmish.WPF is right for your use-case.)

ScottHutchinson commented 4 years ago

I really want Elmish.WPF to work for this. It just seems like too simple a problem to give up on it.

I think there is not a single sample of init returning a command (using Program.mkProgramWpf), so it's difficult for me to understand how that would work and whether it would help in my case.

cmeeren commented 4 years ago

Instead of init returning a command, you can use Program.withSubscription in the chain before Program.run.... I think the end result is exactly the same.

In any case, as I said above:

If you can explain in very simple terms the high-level functionality you are trying to accomplish, I may be able to create a sample that demonstrates how to do it.

ScottHutchinson commented 4 years ago

I want to show a modal dialog, with data initialized based on arguments. I want the user to be able to show that dialog over and over again, each time with different arguments. But I don't want the user to have to click a button on the main window to show the dialog. I want to show it from code. I don't really want the main window to ever be visible. The main window can just be an empty window.

EDIT: Hang on. Maybe I should just be using the main window as the dialog (which is what I started out doing). I'll try that again with what I've learned today, maybe I can get it to work.

cmeeren commented 4 years ago

Thanks. I assume only one such dialog can be open simultaneously? What is it that causes it to be shown?

ScottHutchinson commented 4 years ago

Yes, only one dialog at time, since it is modal. A function call causes it to be shown. If possible, I can just use the main window as the dialog, but I need to be able to hide it or close it between uses.

cmeeren commented 4 years ago

What causes this triggering function to be called? (Just trying to understand the use-case better.)

TysonMN commented 4 years ago

Instead of init returning a command, you can use Program.withSubscription in the chain before Program.run.... I think the end result is exactly the same.

They are.

ScottHutchinson commented 4 years ago

The function is called when the user clicks on an item in a C++ MFC window. It is quite a complicated window with several tabs and a menu that I do not want to re-implement in WPF just so I can show one new dialog.

cmeeren commented 4 years ago

@bender2k14 For reference, do you have a source for that? I remember an issue a while back, probably in the main Elmish repo, where this was discussed and where there may have been ever so slightly different (but that may have been a bug that is now fixed). At least it was not trivially true, as I remember it.

ScottHutchinson commented 4 years ago

https://github.com/elmish/elmish/issues/183#issuecomment-527551338

cmeeren commented 4 years ago

@ScottHutchinson Thanks! So this is where I fall short. I have never used C++ nor MFC, and I have no idea how C++/MFC interfaces/interops with .NET/WPF, where the WPF Application lifecycle is controlled, etc. In short, Elmish.WPF is only intended to run a single WPF app. Is there anything relevant you can tell me about how this works?

ScottHutchinson commented 4 years ago

I think maybe you should ignore the C++ aspect. Just focus on the idea of an F# function with parameters that shows an Elmish.WPF dialog window with data initialized based on those parameters.

ScottHutchinson commented 4 years ago

The function currently looks like below, but it only opens an empty main window and then triggers a ShowDialog case in the update function after the main window closes. Calling the function a second time triggers that case again. I'm still working out how to proceed next.

module PublicAPI =
    open NG_DART_WPF

    let mutable isInitialized = false

    let LoadWindow (msgTypeID: int) (msgTypeName: string) (parentStructName: string) =
        if not isInitialized then
            isInitialized <- true
            let init = App.init msgTypeID msgTypeName parentStructName
            Program.mkSimpleWpf init App.update App.bindings
            |> Program.withSubscription (fun _ -> 
                Cmd.ofSub App.dialogOpeningSubscriber
            )
            |> Program.runWindowWithConfig
                ElmConfig.Default
                (Window())
            |> ignore<int>

        App.raiseDialogOpening { MsgTypeID = msgTypeID; MsgTypeName = msgTypeName; ParentStructName = parentStructName}
cmeeren commented 4 years ago

It's getting late here but I'll try to have a look at it tomorrow, if no-one else does so in the meantime.

In short, the main "error" above is calling Program... outside of the application's entry point. As previously mentioned, this should only be called in the WPF app's entry point (just like the plain WPF Application.RunWindow). So you first need to start a normal, long-running WPF (and Elmish.WPF) app (like the samples show), with a hidden main window if you want, and then exclusively use the Elmish model/update/message stuff to hide/show windows and store/update the necessary state for those windows. When you start the app, as you do above, you can use Program.withSubscription to set up "external triggers" as it were, but again, this is only done once, on app startup.

ScottHutchinson commented 4 years ago

I think maybe this is the WPF application's entry point in my case. I could call it in a separate function, but I'm not sure the result will be any different. The isInitialized value ensures that it is called only once.

ScottHutchinson commented 4 years ago

I tried adding an implicit entry point as shown below, but it blocked the initialization of the MFC application until I closed the WPF window, so I'm thinking there are only two ways I would be able to use Elmish.WPF: (1) Launch a new WPF application in a new process or AppDomain; or (2) if Elmish.WPF could be modified to support running in the context of window.ShowDialog(), which can be called over and over again without ever calling the blocking Application.Run function.

module Main

open System
open System.Windows

[<STAThread()>]
do 
    let win = Window (WindowState = WindowState.Minimized)
    //win.DataContext <- vm :> obj
    let app = new Application() in
    app.Run(win) |> ignore

let init = (do ()); true // from https://stackoverflow.com/a/18619285/5652483
cmeeren commented 4 years ago

I see, thanks for trying that out.

Elmish.WPF is, from the ground up, intended to "be the whole app", as it were. If anyone wants to have a look at which changes would be needed to support running Elmish.WPF separately for separate windows, feel free, but that would also require someone to be willing to help maintain any increased complexity in Elmish.WPF going forward, because I'm not that keen on implementing and supporting it. (Since this is a hobby project, I need to prioritize my resources.)

TysonMN commented 4 years ago

@bender2k14 For reference, do you have a source for that? I remember an issue a while back, probably in the main Elmish repo, where this was discussed...

elmish/elmish#183 (comment)

Yep.

TysonMN commented 4 years ago

If anyone wants to have a look at which changes would be needed to support running Elmish.WPF separately for separate windows, feel free, but that would also require someone to be willing to help maintain any increased complexity in Elmish.WPF going forward, because I'm not that keen on implementing and supporting it.

I am willing to consider this. I just haven't had enough time yet to try things. My plan is to focus on #211 first. I might end up implementing several different approaches to help us focus on the implementation details.

ScottHutchinson commented 4 years ago

I'm going to try to implement a new Program.showDialogWithConfig function. If I can make it work, then I'll submit a pull request. I'm hoping the changes required will be minimal, but we'll see. I'm willing to help maintain it. Of course I welcome any help from @bender2k14. I don't find any guidelines for contributors, so let me know if you'd like me to fork or branch, etc in a particular way.

cmeeren commented 4 years ago

Contributor guidelines are here: https://github.com/elmish/Elmish.WPF/blob/master/.github/CONTRIBUTING.md (they should show up when you create an issue/PR)

ScottHutchinson commented 4 years ago

Thanks. I should have searched.

ScottHutchinson commented 4 years ago

This is looking like it might require nothing more than adding the function below. I'll continue implementing the features of my dialog using that function, but so far it is working perfectly. If it works, then I'll write a sample before submitting a pull request.

/// Starts the Elmish and WPF dispatch loops with the specified configuration.
/// Will show the specified window as a dialog, returning the dialog result.
/// This is a blocking function.
let showDialogWithConfig config (window: Window) program =
  startElmishLoop config window program
  window.ShowDialog ()
TysonMN commented 4 years ago

Is window.ShowDialog () actually blocking?

ScottHutchinson commented 4 years ago

I would say so. From https://docs.microsoft.com/en-us/dotnet/api/system.windows.window.showdialog?view=netframework-4.6.1 "Opens a window and returns only when the newly opened window is closed." ... "ShowDialog shows the window, disables all other windows in the application, and returns only when the window is closed. This type of window is known as a modal window."

cmeeren commented 4 years ago

I'm happy the fix seems so simple. 🙂

If it works, then I'll write a sample before submitting a pull request.

Thanks! As I understand it, this sample should not use Elmish for the main application, only for the dialog. That way we demonstrate how to use Elmish.WPF only for dialogs in an existing non-Elmish app. Do you agree?

Also, we might consider not adding a showDialogWithConfig but a showCustomWithConfig where the user simply supplies a unit -> unit that is run (which can be window.ShowDialog). That way the user has full control.

Also, it would be great if someone can investigate what happens with the Elmish dispatch loop when the window is closed. Is it garbage collected, or does it leak?