sourceafReflux::ProgressDialogue.fan

using gfx
using fwt
using concurrent
using afConcurrent
using afIoc

** (Widget) - 
** A dialogue window that displays an updatable progress bar.
** 
** ![Progress Dialogue]`http://static.alienfactory.co.uk/fantom-docs/afReflux.progressDialogue.png`
**
** Sample usage:
** pre>
** syntax: fantom
** 
** dialogue := ProgressDialogue()
** 
** dialogue.with {
**     it.title = "Look at me!"
**     it.image = Image(`fan://icons/x48/flux.png`)
**     it.closeWhenFinished = false
** }
** 
** result := dialogue.open(reflux.window) |ProgressWorker worker->Obj?| {
**     worker.update(1, 4, "Processing...")
**     Actor.sleep(2sec)
** 
**     worker.update(2, 4, "A Very Long...")
**     Actor.sleep(2sec)
** 
**     worker.update(3, 4, "Process...")
**     Actor.sleep(2sec)
** 
**     worker.update(4, 4, "Done.")
**     return "result!"
** }
** <pre
** 
** 
** 
** Processing
** ==========
** As seen in the example, the work should be performed in the callback func passed to 'open()'. 
** The work func should then make repeated calls to 'ProgressWorker.update()' to update the dialogue and progress bar.
**  
** The callback func is processed in its own thread. This keeps the UI thread free to update the progress dialogue as needed.
** 
** Note that this means the worker function should be immutatble.
** 
** To update other UI components from within the callback func, use 'Desktop':
** 
**   syntax: fantom
** 
**   registry := this.registry
**   Desktop.callAsync |->| {
**       reflux := (Reflux) registry.serviceById(Reflux#.qname)
**       reflux.refresh
**       ...
**   }  
** 
**
** 
** Cancelling
** ==========
** A user may cancel any progress dialogue at any time. 
** Callback funcs should check the status of the 'ProgressWorker.cancelled' flag and return early if set.
** An alternative is to call 'ProgressWorker.update()' often, which throws a 'sys::CancelledErr' if the 'cancelled' flag has been set.
** 
** Should a progress dialogue be cancelled, 'ProgressDialogue.onCancel()' is called. 
** This hook may be overridden to perform custom cancel handling.
** By default the dialogue shows a 'Cancelled by User' message. 
** 
** To mimic a user pressing 'Cancel' the callback func may simply throw a 'sys::CancelledErr'. 
** 
** 
** 
** Error Handling
** ==============
** Should an error occur, 'ProgressDialogue.onError(Err)' is called.
** This hook may be overridden to perform custom cancel handling.
** By default the dialogue shows an error message and displays the stack trace in the details panel. 
** 
** If the 'ProgressDialogue' is autobuilt then the error is added to the 'Errors' service.
** 
**   syntax: fantom
**   dialogue := (ProgressDialogue) registry.autobuild(ProgressDialogue#)
** 
** or the dialogue may be set as an IoC field:
** 
**   syntax: fantom
**   @Autobuild ProgressDialogue dialogue
** 
class ProgressDialogue {

    ** Title string.
    Str title := "Progress Dialogue"

    ** The image displayed to the left of the message.
    Image? image {
        set {
            v := &image = it
            if (_imageWidget != null) {
                safeWidget := Unsafe(_imageWidget)
                Desktop.callAsync |->| { safeWidget.val->image = v }
            }
        }
    }

    ** The message text to display. 
    Str text := "" {
        set {
            v := &text = it
            if (_textWidget != null) {
                safeWidget := Unsafe(_textWidget)
                Desktop.callAsync |->| {
                    safeWidget.val->text = _padToFiveLines(v)
                    if (v.splitLines.size > 5)
                        safeWidget.val->pack
                }
            }
        }
    }
    
    ** The text displayed in the details panel. 
    Str detailText := "" {
        set {
            v := &detailText = it
            if (_detailsWidget != null) {
                safeWidget := Unsafe(_detailsWidget)
                Desktop.callAsync |->| { safeWidget.val->text = v }
            }
        }
    }
    
    // todo: Delay showing the ProgressDialogue - impossible! 
    // Whatever you do ends up blocking the UI thread... :( 
//  ** The amount of time to elapse before the dialogue is displayed.
//  ** This prevents short lived operations from flashing dialogues to the user.
//  ** 
//  ** Set to 'null' to display the dialogue immediately.
//  ** 
//  ** Defaults to '500ms'.
//  Duration? displayAfterDuration := 500ms {
//      set {
//          if (it != null && it < 0ms)
//              throw ArgErr("Duration must be > 0")
//          &displayAfterDuration = (0ms == it) ? null : it
//      }
//  }
    
    ** If 'true' then the dialogue automatically closes when the work is done.
    ** Set to 'false' to keep the dialogue open and have the user manually close it.
    ** Handy to show a final status and / or let the user inspect the details.
    ** 
    ** Defaults to 'true'.
    Bool closeWhenFinished  := true

    @Inject
    private Errors?         _errors
    private Bool            _inProgress
    private Label?          _textWidget
    private Label?          _imageWidget
    internal Text?          _detailsWidget
    private Command?        _okCmd
    private Command?        _cancelCmd
    internal Command?       _detailsCmd
    private ProgressBar?    _progressWidget

    @NoDoc  // Boring!
    new make(|This|? f := null) {
        f?.call(this)
    }
    
    ** Creates and displays a progress dialogue. 
    ** All work is done inside the given callback in a separate thread.
    **
    ** This call blocks until the work is finished and returns what the function returns.
    Obj? open(Window parent, |ProgressWorker->Obj?| callback) {
        if (_inProgress || _textWidget != null)
            throw Err("ProcessDialogue is already open")

        future := (Future?) null
        diag   := _createDialogue(parent)
        diag.onOpen.add |Event e| {
            future = _doWork(ActorPool(), diag, callback)
        }
        diag.open
        // future could be null if _doWork() never had a chance to execute
        return future?.get
    }

    ** Hook for handling cancelled events from the user.
    ** 
    ** By default this sets the dialogue text to 'Cancelled by User'.
    virtual Void onCancel() {
        text = "Cancelled by User"
        detailText += "\n\n----\nCancelled by User"
    }

    ** Hook for handling errors from the 'ProgressWorker' callback function.
    ** 
    ** By default this adds a stack trace to the details panel and sets the text to the error msg. 
    ** 'closeWhenFinished' is also set to 'false'.
    **  
    ** If this progress dialogue was autobuilt by IoC then the 'Err' is also added to the 'Errors' service.
    virtual Void onError(Err err) {
        text  = "ERROR: ${err.typeof.qname} - ${err.msg}"
        image = Image(`fan://icons/x32/err.png`)
        detailText += "\n\n----\nERROR: ${err.traceToStr}"
        closeWhenFinished = false
        
        errorsRef := Unsafe(_errors)
        Desktop.callAsync |->| {
            ((Errors) errorsRef.val).add(err, true)
        }
    }

    private Future _doWork(ActorPool actorPool, Window window, |ProgressWorker->Obj?| callback) {
        winRef  := Unsafe(window)
        diagRef := Unsafe(this)
        // do the work in a separate thread so the UI thread is free to update the dialogue
        return Synchronized(actorPool).async |->Obj?| {
            diag    := (ProgressDialogue) diagRef.val
            worker  := ProgressWorker(winRef.val, diag, diag._progressWidget)
            ((ProgressDialogueCancelCommand) diag._cancelCmd).worker = worker

            cwfBackup := diag.closeWhenFinished
            diag._inProgress = true
            result := null
            try {
                result = callback(worker)
                
                // the callback func may check the cancelled flag and return nicely = no CancelledErr!
                if (worker.cancelled) {
                    _disableCancelButton(diagRef)
                    diag.onCancel()
                }

            } catch (CancelledErr err) {
                _disableCancelButton(diagRef)
                diag.onCancel()

            } catch (Err err) {
                _disableCancelButton(diagRef)
                diag.onError(err)
            }
            
            diag._inProgress = false

            if (diag.closeWhenFinished)
                Desktop.callAsync |->| {
                    win := (Window) winRef.val
                    win.close
                }
            else
                _disableCancelButton(diagRef)
                _enableOkayButton(diagRef)
            
            // clean up
            diag.closeWhenFinished  = cwfBackup
            
            return result
        }
    }
    
    private static Void _disableCancelButton(Unsafe diagRef) {
        Desktop.callAsync |->| {
            diag2 := (ProgressDialogue) diagRef.val
            if (diag2._cancelCmd != null)
                diag2._cancelCmd.enabled = false
        }       
    }

    private static Void _enableOkayButton(Unsafe diagRef) {
        Desktop.callAsync |->| {
            diag2 := (ProgressDialogue) diagRef.val
            if (diag2._okCmd != null)
                diag2._okCmd.enabled = true
        }       
    }
    
    private Window _createDialogue(Window window) {
        t := this.text
        _textWidget = Label { it.text = _padToFiveLines(t) }

        bodyAndImage := (Widget) _textWidget
        
        if (image != null) {
            _imageWidget = Label { it.image = this.&image }
            bodyAndImage = GridPane {
                numCols     = 2
                expandCol   = 1
                halignCells = Halign.fill
                _imageWidget,
                _textWidget,
            }
        }

        _detailsWidget = Text {
            it.multiLine= true
            it.editable = false
            it.prefRows = 10
            it.font     = Desktop.sysFontMonospace
            it.text     = this.&detailText
            it.visible  = false
        }

        _cancelCmd  = ProgressDialogueCancelCommand()
        _detailsCmd = ProgressDialogueDetailsCommand(_detailsWidget)
        commands    := [_cancelCmd, _detailsCmd]
        if (!closeWhenFinished) {
            _okCmd = Dialog.ok { it.enabled = false }
            commands.insert(0, _okCmd)
        }

        buttons := GridPane {
            numCols     = commands.size
            halignCells = Halign.fill
            halignPane  = Halign.right
            uniformRows = true
            uniformCols = true
            hgap        = 4
        }
        commands.each |Command c| {
            buttons.add(ConstraintPane {
                minw = 70
                b := Button.makeCommand(c) { insets = Insets(0, 10, 0, 10) }
                it.add(b)
            })
        }

        _progressWidget = ProgressBar()

        content := GridPane {
            expandCol = 0
            expandRow = 0
            valignCells = Valign.fill
            halignCells = Halign.fill
            InsetPane(16) {
                ConstraintPane {
                    minw = 350
                    bodyAndImage,
                },
            },
            InsetPane() {
                insets = Insets(0, 16, 16, 16)
                _progressWidget,
            },
            InsetPane {
                insets = Insets(0, 16, 16, 16)
                buttons,
            },
            _detailsWidget,
        }

        return Window(window) {
            it.title    = this.title
            it.content  = content
            it.mode     = WindowMode.appModal
            it.onClose.add |Event e| {
                // don't let users manually close the progress dialogue while it's executing
                if (_inProgress) {
                    e.consume
                    return
                }
    
                // clean up
                _textWidget         = null
                _imageWidget        = null
                _detailsWidget      = null
                _progressWidget     = null
                _okCmd              = null
                _cancelCmd          = null
            }
        }
    }
    
    static Str _padToFiveLines(Str text) {
        noOfLines := text.splitLines.size
        return (noOfLines < 5) ? text + "".padl(5 - noOfLines, '\n') : text
    }
}


** Used by [ProgressDialogues]`ProgressDialogue` to update the progress bar.
** 
** Example:
** pre>
** dialogue.open(window) |ProgressWorker worker| {
**     worker.update(1, 4, "Processing...")
**     Actor.sleep(2sec)
** 
**     worker.update(2, 4, "A Very Long...")
**     Actor.sleep(2sec)
** 
**     worker.update(3, 4, "Process...")
**     Actor.sleep(2sec)
** 
**     worker.update(4, 4, "Done.")
** }
** <pre 
class ProgressWorker {
    private Window              window
    private ProgressDialogue    dialogue
    private ProgressBar         progressWidget
    
    ** The image displayed in the progress dialogue.
    Image? image {
        get { dialogue.image }
        set { dialogue.image = it }
    }

    ** The message displayed in the progress dialogue. 
    Str text {
        get { dialogue.text }
        set { dialogue.text = it } 
    }

    ** The text displayed in the details panel. 
    Str detailText {
        get { dialogue.detailText }
        set { dialogue.detailText = it }
    }

    ** Returns 'true' if the user clicked the Cancel button.
    Bool cancelled {
        internal set
    }

    internal new make(Window window, ProgressDialogue dialogue, ProgressBar progressWidget) {
        this.window         = window
        this.dialogue       = dialogue
        this.progressWidget = progressWidget
    }
    
    ** Updates the progress bar to show work done.
    ** 'msg' is optional and sets the dialogue text and is appended to the detail text.
    ** 
    ** If the dialogue has been cancelled then this throws a 'CancelledErr'. 
    Void update(Int workDone, Int workTotal, Str? msg := null) {
        if (cancelled) throw CancelledErr("Progress dialogue cancelled.")

        // set text first so it may be overwritten should an Err occur later
        if (msg != null) {
            this.text = msg
            this.detailText += detailText.isEmpty ? msg : "\n" + msg
        }

        safeProgress := Unsafe(progressWidget)
        Desktop.callAsync |->| {
            progressBar := (ProgressBar) safeProgress.val
            progressBar.with {
                it.val = workDone
                it.max = workTotal
            }
        }
    }
    
    ** Call to manually shows / hide the details panel.
    Void showHideDetails(Bool showHide) {
        winRef := Unsafe(window)
        widRef := Unsafe(dialogue._detailsWidget)
        cmdRef := Unsafe(dialogue._detailsCmd)
        Desktop.callAsync |->| {
            if (showHide) {
                widRef.val->visible = true
                cmdRef.val->selected = true
                winRef.val->pack
            } else {
                widRef.val->visible = false
                cmdRef.val->selected = false
                winRef.val->pack
            }
        }
    }

    ** Convenience method for:
    **  - Changing the dialogue image to a warning image 
    **  - Opening the detail text and showing the msg
    **  - Ensuring the dialog doesn't close when finsihed (so users can read the warning msg)
    Void warn(Str msg) {
        image = Image(`fan://icons/x32/warn.png`)
        detailText += "\n\n----\nWARN: ${msg}"
        showHideDetails(true)
        dialogue.closeWhenFinished = false
    }
    
    internal Void cancel() {
        this.cancelled = true
    }
}

internal class ProgressDialogueDetailsCommand : Command {
    Widget details

    new make(Widget details) : super.makeLocale(Dialog#.pod, "details") {
        this.details = details
        this.mode    = CommandMode.toggle
    }

    override Void invoked(Event? e) {
        details.visible = selected
        window.pack
    }
}

internal class ProgressDialogueCancelCommand : Command {
    ProgressWorker? worker

    new make() : super.makeLocale(Dialog#.pod, "cancel") { }

    override Void invoked(Event? e) {
        // worker could be null if _doWork() never had a chance to execute
        // if that's the case, the user can still close the dialogue
        worker?.cancel
    }
}