sourceafExplorer::TextEditor.fan

using afIoc
using afReflux
using syntax
using gfx
using fwt

** (View) - 
** A text editor with syntax highlighting. Borrowed from [fluxtext]`pod:fluxText`.
class TextEditor : View {
    @Inject private Scope           scope
    @Inject private Explorer        explorer
    @Inject private Reflux          reflux
    @Inject private AppStash        stash
    @Inject private GlobalCommands  globalCommands
    @Inject private Dialogues       dialogues
    @Inject private Preferences     preferences
            private EdgePane        edgePane
    
    
    ** The 'File' being edited.
            File?               file
    internal TextEditorPrefs?   options
    internal Charset?           charset
    internal SyntaxRules?       rules
    internal RichText?          richText
    internal TextDoc?           doc

    private TextEditorController?   controller
    internal FindBar                find
    internal DateTime?              fileTimeAtLoad
    internal Label                  caretField      := Label()
    internal Label                  charsetField    := Label()
    
    Bool wordWrap {
        set {
            &wordWrap = it
            stashPrefs
            newWidgets
            restorePrefs
        }
    }

    protected new make(GlobalCommands globCmds, |This| in) : super(in) {
        find = scope.build(FindBar#, [this])
        content = edgePane = EdgePane {
            it.top = buildToolBar
            it.bottom = buildStatusBar
        }
        &wordWrap = globCmds["afExplorer.cmdWordWrap"].command.selected
    }
    
    @NoDoc
    override Void onActivate() {
        globalCommands["afExplorer.cmdFind"             ].addInvoker("afExplorer.textEditor", |Event? e|    { find.showFind } )
        globalCommands["afExplorer.cmdFind"             ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        globalCommands["afExplorer.cmdFindNext"         ].addInvoker("afExplorer.textEditor", |Event? e|    { find.next } )
        globalCommands["afExplorer.cmdFindNext"         ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        globalCommands["afExplorer.cmdFindPrev"         ].addInvoker("afExplorer.textEditor", |Event? e|    { find.prev } )
        globalCommands["afExplorer.cmdFindPrev"         ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        globalCommands["afExplorer.cmdReplace"          ].addInvoker("afExplorer.textEditor", |Event? e|    { find.showFindReplace } )
        globalCommands["afExplorer.cmdReplace"          ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        globalCommands["afExplorer.cmdGoto"             ].addInvoker("afExplorer.textEditor", |Event? e|    { controller?.onGoto(e) } )
        globalCommands["afExplorer.cmdGoto"             ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        globalCommands["afReflux.cmdSaveAs"             ].addInvoker("afExplorer.textEditor", |Event? e|    { this->onSaveAs() } )
        globalCommands["afReflux.cmdSaveAs"             ].addEnabler("afExplorer.textEditor", |  ->Bool|    { true } )
        restorePrefs
    }

    @NoDoc
    override Void onDeactivate() {
        globalCommands["afExplorer.cmdFind"             ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdFind"             ].removeEnabler("afExplorer.textEditor")
        globalCommands["afExplorer.cmdFindNext"         ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdFindNext"         ].removeEnabler("afExplorer.textEditor")
        globalCommands["afExplorer.cmdFindPrev"         ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdFindPrev"         ].removeEnabler("afExplorer.textEditor")
        globalCommands["afExplorer.cmdReplace"          ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdReplace"          ].removeEnabler("afExplorer.textEditor")
        globalCommands["afExplorer.cmdGoto"             ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdGoto"             ].removeEnabler("afExplorer.textEditor")
        globalCommands["afReflux.cmdSaveAs"             ].removeEnabler("afExplorer.textEditor")
        globalCommands["afReflux.cmdSaveAs"             ].removeInvoker("afExplorer.textEditor")
        
        // onBlur doesn't always fire!?
        globalCommands["afExplorer.cmdSelectAll"        ].removeInvoker("afExplorer.textEditor")
        globalCommands["afExplorer.cmdSelectAll"        ].removeEnabler("afExplorer.textEditor")        

        stashPrefs
    }
    
    @NoDoc
    override Void load(Resource resource) {
        super.load(resource)
        file = ((FileResource) resource).file

        prefsFileName
            := preferences.findFile("fluxText-${file?.ext}.fog").exists
            ?  "fluxText-${file?.ext}.fog"
            :  "fluxText.fog"
        options = preferences.loadPrefs(TextEditorPrefs#, prefsFileName)
        charset = options.charset
        
        loadDoc
        charsetField.text = charset.toStr

        newWidgets
        restorePrefs
    }
    
    private Void newWidgets() {
        // create rich text widget
        richText = RichText { model = doc; border = false; wrap = wordWrap }
        richText.font = options.font
        richText.tabSpacing = options.tabSpacing

        // initialize controller
        controller = scope.build(TextEditorController#, [this])
        controller.register
        controller.updateCaretStatus

        edgePane.center = richText
        edgePane.relayout
    }
    
    private Void stashPrefs() {
        // we want to keep the scroll pos when switching between views, 
        // but clear it when closing the tab - so when re-opened, we're at the top again!
        if (stash["${resource?.uri}.textEditor.clear"] == true)
            stash.remove("${resource?.uri}.textEditor.clear")
        else {
            // save viewport and caret position
            stash["${resource?.uri}.textEditor.caretOffset"] = richText.caretOffset
            stash["${resource?.uri}.textEditor.topLine"]     = richText.topLine
        }
    }

    private Void restorePrefs() {
        if (richText == null) return

        // restore viewport and caret position
        caretOffset := stash["${resource?.uri}.textEditor.caretOffset"]
        topLine     := stash["${resource?.uri}.textEditor.topLine"]
        if (caretOffset != null) richText.caretOffset = caretOffset
        if (topLine != null)     richText.topLine = topLine     
        richText.focus
    }

    @NoDoc
    override Void save() {
        doSave(this.file)
        fileTimeAtLoad = file.modified
        super.save
    }

    ** Callback for when the 'afReflux.cmdSaveAs' 'GlobalCommand' is activated.
    ** Default implementation is to perform the *save as*.
    @NoDoc
    virtual Void onSaveAs() {   
        file := (File?) FileDialog {
            it.mode = FileDialogMode.saveFile
            if (file != null) {
                it.dir  = this.file.parent
                it.name = this.file.name
                it.filterExts = ["*.${this.file.ext}", "*.*"]
            } else
                it.filterExts = ["*.*"]
        }.open(reflux.window)

        if (file != null) {
            doSave(file)
            fileRes := scope.build(FileResource#, [file])
            reflux.loadResource(fileRes)
            
            isDirty = false // mark as not dirty so confirmClose() doesn't give a dialog
            reflux.closeView(this, true)

            // refresh any views on the containing directory
            dirRes := scope.build(FolderResource#, [file.parent])
            reflux.refresh(dirRes)
        }
    }
    
    private Void doSave(File file) {
        out := file.out { it.charset = this.charset }
        try     doc.save(out)
        finally out.close
    }
    
    @NoDoc
    override Bool confirmClose(Bool force) {
        stash.remove("${resource?.uri}.textEditor.caretOffset")
        stash.remove("${resource?.uri}.textEditor.topLine")
        stash["${resource?.uri}.textEditor.clear"] = true
        
        if (!isDirty) return true
        
        if (force) {
            r := dialogues.openQuestion("Save changes to $resource.name?", [dialogues.yes, dialogues.no])
            if (r == dialogues.yes) save

        } else {
            r := dialogues.openQuestion("Save changes to $resource.name?\n\nClick 'Cancel' to continue editing.", [dialogues.yes, dialogues.no, Dialog.cancel])
            if (r == dialogues.cancel) return false
            if (r == dialogues.yes) save
        }
        
        // clear flag to reset the tab text, because the tab (not this view panel) gets reused if we're switching views
        isDirty = false

        return true
    }
    
    internal Void loadDoc() {
        // read document into memory, if we fail with the
        // configured charset, then fallback to ISO 8859-1
        // which will always "work" since it is byte based
        lines := readAllLines
        if (lines == null) {
            charset = Charset.fromStr("ISO-8859-1")
            lines    = readAllLines
        }

        // save this time away to check on focus events
        fileTimeAtLoad = file.modified

        // figure out what syntax file to use
        // based on file extension and shebang
        rules = SyntaxRules.loadForFile(file, lines.first) ?: SyntaxRules()

        // load document
        doc = TextDoc(options, rules)
        doc.load(lines)
    }

    private Str[]? readAllLines() {
        in := file.in { it.charset = this.charset }
        try     return in.readAllLines
        catch   return null
        finally in.close
    }

    private Widget buildToolBar() {
        return EdgePane {
//          top = InsetPane(4,4,5,4) {
//              ToolBar {
//                  addCommand(globalCommands["afReflux.cmdSave"].command)
//                  addSep
////                    addCommand(frame.command(CommandId.cut))
////                    addCommand(frame.command(CommandId.copy))
////                    addCommand(frame.command(CommandId.paste))
////                    addSep
////                    addCommand(frame.command(CommandId.undo))
////                    addCommand(frame.command(CommandId.redo))
//              },
//          }
            bottom = find
        }
    }

    private Widget buildStatusBar() {
        return GridPane {
            it.numCols = 2
            it.hgap = 10
            it.halignPane = Halign.right
            it.add(caretField)
            it.add(charsetField)
        }
    }
}