├── Docs ├── img │ ├── logo.ico │ ├── logo.png │ ├── favicon.ico │ ├── logo128.png │ ├── logo400.png │ ├── repoCard.png │ ├── HelloWorld.png │ └── The best Favicon Generator (completely free) - favicon.io.url ├── content │ └── fsdocs-theme.css └── WPF example.fsx ├── .config └── dotnet-tools.json ├── .gitignore ├── .github ├── workflows │ ├── build.yml │ ├── docs.yml │ ├── releaseNuget.yml │ ├── outdatedDotnetTool.yml │ └── outdatedNuget.yml └── dependabot.yml ├── Src ├── Util.fs ├── Brush.fs ├── Sync.fs ├── LogTextWriter.fs ├── AvalonLog.fsproj ├── TextColor.fs ├── SelectedTextHighlighter.fs └── AvalonLog.fs ├── AvalonLog.sln ├── LICENSE.md ├── README.md └── CHANGELOG.md /Docs/img/logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/logo.ico -------------------------------------------------------------------------------- /Docs/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/logo.png -------------------------------------------------------------------------------- /Docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/favicon.ico -------------------------------------------------------------------------------- /Docs/img/logo128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/logo128.png -------------------------------------------------------------------------------- /Docs/img/logo400.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/logo400.png -------------------------------------------------------------------------------- /Docs/img/repoCard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/repoCard.png -------------------------------------------------------------------------------- /Docs/img/HelloWorld.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/goswinr/AvalonLog/HEAD/Docs/img/HelloWorld.png -------------------------------------------------------------------------------- /Docs/img/The best Favicon Generator (completely free) - favicon.io.url: -------------------------------------------------------------------------------- 1 | [InternetShortcut] 2 | URL=https://favicon.io/ 3 | -------------------------------------------------------------------------------- /Docs/content/fsdocs-theme.css: -------------------------------------------------------------------------------- 1 | code>span>span { 2 | color: rgb(133, 133, 133); 3 | /* font-weight: bold; */ 4 | }; 5 | 6 | -------------------------------------------------------------------------------- /.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "fsdocs-tool": { 6 | "version": "21.0.0", 7 | "commands": [ 8 | "fsdocs" 9 | ], 10 | "rollForward": false 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vs/ 2 | .vscode/ 3 | bin/ 4 | obj/ 5 | fable_modules/ 6 | node_modules/ 7 | js/ 8 | ts/ 9 | # *.js 10 | # *.ts 11 | 12 | # FSharp.Formatting 13 | .fsdocs/ 14 | output/ 15 | tmp/ 16 | DocsGenerated/ 17 | 18 | # needed FSharp.Formatting. It will be generated by after build action: 19 | Docs/index.md -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | build: 13 | runs-on: windows-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v6 18 | 19 | - name: Setup .NET 20 | uses: actions/setup-dotnet@v5 21 | with: 22 | dotnet-version: '10.x' 23 | 24 | - name: Build fsproj 25 | run: dotnet build --configuration Release 26 | 27 | 28 | -------------------------------------------------------------------------------- /Src/Util.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | /// Shadows the ignore function to only accept structs 4 | /// This is to prevent accidentally ignoring partially applied functions that would return struct 5 | module internal Util = 6 | 7 | /// Shadows the original 'ignore' function 8 | /// This is to prevent accidentally ignoring partially applied functions 9 | [] 10 | let inline ignore<'T> (x:'T) = ignore x 11 | 12 | /// The same as 'not isNull' 13 | let inline notNull x = match x with null -> false | _ -> true 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Update to newer version of GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "weekly" 8 | 9 | # don't do nuget here, WPF does not build correctly on linux 10 | # to work on FSharp.Core too the fsproj needs : true 11 | # and in README.md with two spaces for nuget.org 22 | run: | 23 | $content = Get-Content -Path README.md -Raw 24 | $content = $content -replace "
", " " 25 | Set-Content -Path README.md -Value $content 26 | 27 | 28 | - name: Build 29 | run: dotnet build --configuration Release 30 | 31 | - name: Check version consistency of git tag and CHANGELOG.md 32 | # needs in fsproj: 33 | # 34 | # 35 | # 36 | id: check_version 37 | shell: bash 38 | run: | 39 | CHANGELOG_VERSION=$(cat ./Src/bin/ChangelogVersion.txt | tr -d '[:space:]') 40 | echo "CHANGELOG_VERSION=$CHANGELOG_VERSION" 41 | echo "github.ref_name=${{ github.ref_name }}" 42 | if [ "${{ github.ref_name }}" != "$CHANGELOG_VERSION" ]; then 43 | echo "Version mismatch: git tag (${{ github.ref_name }}) and version in CHANGELOG.md ($CHANGELOG_VERSION) are not the same." 44 | exit 1 45 | fi 46 | 47 | 48 | - name: Publish NuGet package 49 | run: dotnet nuget push ./Src/bin/Release/AvalonLog.${{ github.ref_name}}.symbols.nupkg --api-key ${{ secrets.NUGET_API_KEY }} --source https://api.nuget.org/v3/index.json -------------------------------------------------------------------------------- /Src/Brush.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | /// Utility functions for System.Windows.Media.SolidColorBrush 4 | module Brush = 5 | open System 6 | open System.Windows.Media // for SolidColorBrush 7 | 8 | /// Make it thread-safe and faster 9 | let inline freeze(br:SolidColorBrush)= 10 | if not br.IsFrozen then 11 | if br.CanFreeze then br.Freeze() 12 | //else eprintfn "Could not freeze SolidColorBrush: %A" br 13 | br 14 | 15 | /// Clamp int to byte between 0 and 255 16 | let inline clampToByte (i:int) = 17 | if i <= 0 then 0uy 18 | elif i >= 255 then 255uy 19 | else byte i 20 | 21 | /// Get a frozen brush of red, green and blue values. 22 | /// int gets clamped to 0-255 23 | let inline ofRGB r g b = 24 | SolidColorBrush(Color.FromArgb(255uy, clampToByte r, clampToByte g, clampToByte b)) 25 | |> freeze 26 | 27 | /// Get a transparent frozen brush of alpha, red, green and blue values. 28 | /// int gets clamped to 0-255 29 | let inline ofARGB a r g b = 30 | SolidColorBrush(Color.FromArgb(clampToByte a, clampToByte r, clampToByte g, clampToByte b)) 31 | |> freeze 32 | 33 | 34 | /// Adds bytes to each color channel to increase brightness, negative values to make darker. 35 | /// Result will be clamped between 0 and 255 36 | let inline changeLuminance (amount:int) (col:Windows.Media.Color)= 37 | let r = int col.R + amount |> clampToByte 38 | let g = int col.G + amount |> clampToByte 39 | let b = int col.B + amount |> clampToByte 40 | Color.FromArgb(col.A, r,g,b) 41 | 42 | /// Adds bytes to each color channel to increase brightness 43 | /// result will be clamped between 0 and 255 44 | let brighter (amount:int) (br:SolidColorBrush) = SolidColorBrush(Color = changeLuminance amount br.Color) 45 | 46 | /// Removes bytes from each color channel to increase darkness, 47 | /// result will be clamped between 0 and 255 48 | let darker (amount:int) (br:SolidColorBrush) = SolidColorBrush(Color = changeLuminance -amount br.Color) 49 | 50 | 51 | /// Utility functions for System.Windows.Media.Pen 52 | module Pen = 53 | open System 54 | open System.Windows.Media // for Pen 55 | open Brush 56 | 57 | /// Make it thread-safe and faster 58 | let inline freeze(pen:Pen)= 59 | if not pen.IsFrozen then 60 | if pen.CanFreeze then pen.Freeze() 61 | //else eprintfn "Could not freeze Pen: %A" pen 62 | pen 63 | -------------------------------------------------------------------------------- /Src/Sync.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | 4 | open System 5 | open System.Threading 6 | open System.Windows.Threading 7 | 8 | /// Threading Utils to setup and access the SynchronizationContext 9 | /// and evaluate any function on UI thread (SyncAvalonLog.doSync(f)) 10 | type internal SyncAvalonLog private () = 11 | 12 | static let mutable errorFileWrittenOnce = false // to not create more than one error file on Desktop per app run 13 | 14 | static let mutable ctx : SynchronizationContext = null // will be set on first access 15 | 16 | /// To ensure SynchronizationContext is set up. 17 | /// Optionally writes a log file to the desktop if it fails, since these errors can be really hard to debug 18 | static let installSynchronizationContext (logErrorsOnDesktop) = 19 | if SynchronizationContext.Current = null then 20 | // https://stackoverflow.com/questions/10448987/dispatcher-currentdispatcher-vs-application-current-dispatcher 21 | DispatcherSynchronizationContext(Windows.Application.Current.Dispatcher) |> SynchronizationContext.SetSynchronizationContext 22 | ctx <- SynchronizationContext.Current 23 | 24 | if isNull ctx && logErrorsOnDesktop && not errorFileWrittenOnce then 25 | // reporting this to the UI instead would not work since there is no sync context for the UI 26 | let time = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss-fff") // to ensure unique file names 27 | let filename = sprintf "AvalonLog-SynchronizationContext setup failed-%s.txt" time 28 | let desktop = Environment.GetFolderPath(Environment.SpecialFolder.Desktop) 29 | let file = IO.Path.Combine(desktop,filename) 30 | try IO.File.WriteAllText(file, "Failed to get DispatcherSynchronizationContext") with _ -> () // file might be open or locked 31 | errorFileWrittenOnce <- true 32 | failwith ("See" + file) 33 | 34 | 35 | /// The UI SynchronizationContext to switch to inside async work-flows 36 | /// Accessing this member from any thread will set up the sync context first if it is not there yet. 37 | static member context = 38 | if isNull ctx then installSynchronizationContext(true) 39 | ctx 40 | 41 | /// Runs function on UI thread 42 | static member doSync(func) = 43 | //Windows.Application.Current.Dispatcher.Invoke(func) // would not propagate exceptions ? 44 | async { 45 | do! Async.SwitchToContext SyncAvalonLog.context 46 | func() 47 | } |> Async.StartImmediate 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /.github/workflows/outdatedDotnetTool.yml: -------------------------------------------------------------------------------- 1 | name: Check dotnet tools # this name shows in Readme badge 2 | # In the Github UI this workflow need: Settings -> Actions -> General -> Workflow Permissions: 3 | # 'Read and write permissions' and 4 | # 'Allow Github Actions to create and approve pull requests' 5 | 6 | on: 7 | # Allows you to run this workflow manually from the Actions tab in Github.com 8 | workflow_dispatch: 9 | 10 | schedule: 11 | - cron: '0 0 * * *' # Runs daily at midnight UTC 12 | # - cron: '0 * * * *' # Runs every hour for testing 13 | # - cron: '*/6 * * * *' # Runs every 6 minutes for testing 14 | 15 | # push: cannot trigger a pull request , see https://github.com/peter-evans/create-pull-request/tree/v7/?tab=readme-ov-file#token 16 | 17 | permissions: # https://github.com/peter-evans/create-pull-request/tree/v7/?tab=readme-ov-file#token 18 | contents: write 19 | pull-requests: write 20 | 21 | jobs: 22 | nuget-update: 23 | runs-on: windows-latest # so that WPF build works too 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v6 28 | 29 | - name: Setup .NET 30 | uses: actions/setup-dotnet@v5 31 | with: 32 | dotnet-version: '10.x' # Specify the .NET version you are using 33 | 34 | # (1) update dotnet tools 35 | - name: Update dotnet tools 36 | id: update-dotnet-tools 37 | run: dotnet tool update --all --prerelease --verbosity minimal > tool-update.txt 2>&1 38 | 39 | - name: Parse tool update output 40 | shell: pwsh 41 | run: | 42 | $output = Get-Content tool-update.txt 43 | $updatedTools = $output | Where-Object { $_ -match "successfully updated" } 44 | $toolNames = $updatedTools | ForEach-Object { if ($_ -match "'([^']+)'") { $matches[1] } } | Where-Object { $_ } 45 | $toolList = "Bump dotnet tools: " + ($toolNames -join ', ') 46 | echo "COMMIT_MSG=$toolList" >> $env:GITHUB_ENV 47 | 48 | - name: Read tool-update.txt file 49 | id: read-tool-update 50 | uses: juliangruber/read-file-action@v1 51 | with: 52 | path: ./tool-update.txt 53 | 54 | - name: Delete tool-update.txt file 55 | shell: pwsh 56 | run: Remove-Item -Path tool-update.txt -Force 57 | 58 | # (3) create a PR with the updated dependencies 59 | # This will not create a duplicate PR if one exists already 60 | - name: Create Pull Request 61 | uses: peter-evans/create-pull-request@v7 62 | with: 63 | commit-message: ${{ env.COMMIT_MSG }} 64 | committer: github-actions[bot] 65 | author: dotnet-outdated[bot] 66 | branch: dotnet-tool-update-bot 67 | delete-branch: true 68 | title: ${{ env.COMMIT_MSG }} 69 | body: ${{ steps.read-tool-update.outputs.content }} 70 | labels: "dotnet-tool-update" 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Logo](https://raw.githubusercontent.com/goswinr/AvalonLog/main/Docs/img/logo128.png) 2 | # AvalonLog 3 | 4 | [![AvalonLog on nuget.org](https://img.shields.io/nuget/v/AvalonLog)](https://www.nuget.org/packages/AvalonLog/) 5 | [![Build Status](https://github.com/goswinr/AvalonLog/actions/workflows/build.yml/badge.svg)](https://github.com/goswinr/AvalonLog/actions/workflows/build.yml) 6 | [![Docs Build Status](https://github.com/goswinr/AvalonLog/actions/workflows/docs.yml/badge.svg)](https://github.com/goswinr/AvalonLog/actions/workflows/docs.yml) 7 | [![Check NuGet](https://github.com/goswinr/AvalonLog/actions/workflows/outdatedNuget.yml/badge.svg)](https://github.com/goswinr/AvalonLog/actions/workflows/outdatedNuget.yml) 8 | [![Check dotnet tools](https://github.com/goswinr/AvalonLog/actions/workflows/outdatedDotnetTool.yml/badge.svg)](https://github.com/goswinr/AvalonLog/actions/workflows/outdatedDotnetTool.yml) 9 | [![license](https://img.shields.io/github/license/goswinr/AvalonLog)](LICENSE.md) 10 | ![code size](https://img.shields.io/github/languages/code-size/goswinr/AvalonLog.svg) 11 | 12 | AvalonLog is a fast and thread-safe WPF text log viewer for colored text. Including F# `printf` formatting. Based on [AvalonEditB](https://github.com/goswinr/AvalonEditB). Works on .NET Framework 4.7.2 and .NET 7.0+ 13 | 14 | Thread-safe means that it can be called from any thread. 15 | 16 | Fast means 17 | 18 | - it buffers repeated print calls and updates the view maximum 20 times per second. see [source](https://github.com/goswinr/AvalonLog/blob/main/Src/AvalonLog.fs#L222) 19 | 20 | - Avalonedit is fast, the view is virtualized. It can easily handle thousands of lines. 21 | 22 | ### Use with F# 23 | 24 | Here an short example for F# interactive in .NET Framework. 25 | (for net9 you would have to use it in a project) 26 | 27 | ```fsharp 28 | #r "PresentationCore" 29 | #r "PresentationFramework" 30 | #r "WindowsBase" 31 | 32 | #r "nuget: AvalonLog" 33 | 34 | open System.Windows 35 | 36 | let log = new AvalonLog.AvalonLog() // The main class wrapping an Avalonedit TextEditor as append only log. 37 | 38 | // create some printing functions by partial application: 39 | let red = log.printfColor 255 0 0 // without newline 40 | let blue = log.printfnColor 0 0 255 // with newline 41 | let green = log.printfnColor 0 155 0 // with newline 42 | 43 | // print to log using F# printf formatting 44 | red "Hello, " 45 | blue "World!" 46 | red "The answer" 47 | green " is %d." (40 + 2) 48 | 49 | Application().Run(Window(Content=log)) // show WPF window 50 | ``` 51 | this will produce 52 | 53 | ![WPF window](https://raw.githubusercontent.com/goswinr/AvalonLog/main/Docs/img/HelloWorld.png) 54 | 55 | ### Use in C# 56 | 57 | ```csharp 58 | public void AppendWithBrush(SolidColorBrush br, string s) 59 | ``` 60 | and similar functions on the `AvalonLog` instance. 61 | 62 | > [!CAUTION] 63 | > When used from C# add a reference to FSharp.Core 6.0.7 or higher. 64 | 65 | 66 | ### Full API Documentation 67 | 68 | [goswinr.github.io/AvalonLog](https://goswinr.github.io/AvalonLog/reference/avalonlog.html) 69 | 70 | ### Download 71 | 72 | AvalonLog is available as [NuGet package](https://www.nuget.org/packages/AvalonLog). 73 | 74 | ### How to build 75 | 76 | Just run `dotnet build` 77 | 78 | ### Changelog 79 | see [CHANGELOG.md](https://github.com/goswinr/AvalonLog/blob/main/CHANGELOG.md) 80 | 81 | ### License 82 | 83 | [MIT](https://github.com/goswinr/AvalonLog/blob/main/LICENSE.md) 84 | 85 | Logo by [LovePik](https://lovepik.com/image-401268798/crystal-parrot-side-cartoon.html) 86 | 87 | -------------------------------------------------------------------------------- /Src/LogTextWriter.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | open AvalonLog.Util 4 | open AvalonLog.Brush 5 | open System 6 | open System.IO 7 | open System.Threading 8 | open AvalonEditB 9 | open System.Windows.Media // for color brushes 10 | open System.Text 11 | open System.Diagnostics 12 | open System.Windows.Controls 13 | open AvalonEditB.Document 14 | 15 | 16 | /// A TextWriter that writes using a function. 17 | /// To set Console.Out to a text writer get one via AvalonLog.GetTextWriter(red,green,blue) 18 | type LogTextWriter(write:string->unit, writeLine:string->unit) = 19 | inherit TextWriter() 20 | override _.Encoding = Text.Encoding.Default // ( UTF-16 ) 21 | 22 | override _.Write (s:string) = 23 | //if s.Contains "\u001b" then write ("esc"+s) else write ("?"+s) //debugging for using spectre ?? https://github.com/spectreconsole/spectre.console/discussions/573 24 | write (s) 25 | 26 | override _.WriteLine (s:string) = // actually never used in F# printfn, but maybe buy other too using the console or error out , see https://github.com/dotnet/fsharp/issues/3712 27 | //if s.Contains "\u001b" then writeLine ("eSc"+s) else writeLine ("?"+s) 28 | writeLine (s) 29 | 30 | override _.WriteLine () = 31 | writeLine ("") 32 | 33 | 34 | (* 35 | trying to enable ANSI Control sequences for https://github.com/spectreconsole/spectre.console 36 | 37 | but doesn't work yet ESC char seams to be swallowed by Console.SetOut to textWriter. see: 38 | 39 | //https://stackoverflow.com/a/34078058/969070 40 | //let stdout = Console.OpenStandardOutput() 41 | //let con = new StreamWriter(stdout, Encoding.ASCII) 42 | 43 | The .Net Console.WriteLine uses an internal __ConsoleStream that checks if the Console.Out is as file handle or a console handle. 44 | By default it uses a console handle and therefor writes to the console by calling WriteConsoleW. In the remarks you find: 45 | 46 | Although an application can use WriteConsole in ANSI mode to write ANSI characters, consoles do not support ANSI escape sequences. 47 | However, some functions provide equivalent functionality. For more information, see SetCursorPos, SetConsoleTextAttribute, and GetConsoleCursorInfo. 48 | 49 | To write the bytes directly to the console without WriteConsoleW interfering a simple file-handle/stream will do which is achieved by calling OpenStandardOutput. 50 | By wrapping that stream in a StreamWriter so we can set it again with Console.SetOut we are done. The byte sequences are send to the OutputStream and picked up by AnsiCon. 51 | 52 | let strWriter = l.AvalonLog.GetStreamWriter( LogColors.consoleOut) // Encoding.ASCII ?? 53 | Console.SetOut(strWriter) 54 | 55 | // A TextWriter that writes using a function. 56 | // To set Console.Out to a text writer get one via AvalonLog.GetTextWriter(red,green,blue) 57 | type LogStreamWriter(ms:MemoryStream,write,writeLine) = 58 | inherit StreamWriter(ms) 59 | override _.Encoding = Text.Encoding.Default // ( UTF-16 ) 60 | override _.Write (s:string) : unit = 61 | if s.Contains "\u001b" then write ("esc"+s) else write ("?"+s) //use specter ?? https://github.com/spectreconsole/spectre.console/discussions/573 62 | //write (s) 63 | override _.WriteLine (s:string) : unit = // actually never used in F# printfn, but maybe buy other too using the console or error out , see https://github.com/dotnet/fsharp/issues/3712 64 | if s.Contains "\u001b" then writeLine ("eSc"+s) else writeLine ("?"+s) 65 | //writeLine (s) 66 | override _.WriteLine () = writeLine ("") 67 | *) 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /.github/workflows/outdatedNuget.yml: -------------------------------------------------------------------------------- 1 | name: Check NuGet # this name shows in Readme badge 2 | # In the Github UI this workflow need: Settings -> Actions -> General -> Workflow Permissions: 3 | # 'Read and write permissions' and 4 | # 'Allow Github Actions to create and approve pull requests' 5 | 6 | # for FSharp.Core to be updated use 7 | # true 8 | # and the 'Include' instead of the 'Update' syntax: 9 | # 10 | 11 | on: 12 | # Allows you to run this workflow manually from the Actions tab in Github.com 13 | workflow_dispatch: 14 | 15 | schedule: 16 | - cron: '0 0 * * *' # Runs daily at midnight UTC 17 | # - cron: '0 * * * *' # Runs every hour for testing 18 | # - cron: '*/6 * * * *' # Runs every 6 minutes for testing 19 | 20 | # push: cannot trigger a pull request , see https://github.com/peter-evans/create-pull-request/tree/v7/?tab=readme-ov-file#token 21 | 22 | permissions: # https://github.com/peter-evans/create-pull-request/tree/v7/?tab=readme-ov-file#token 23 | contents: write 24 | pull-requests: write 25 | 26 | jobs: 27 | nuget-update: 28 | runs-on: windows-latest # so that WPF build works too 29 | 30 | steps: 31 | - name: Checkout repository 32 | uses: actions/checkout@v6 33 | 34 | - name: Setup .NET 35 | uses: actions/setup-dotnet@v5 36 | with: 37 | dotnet-version: '10.x' # Specify the .NET version you are using 38 | 39 | - name: Restore NuGet packages 40 | run: dotnet restore 41 | 42 | - name: Install dotnet-outdated 43 | run: dotnet tool install -g dotnet-outdated-tool 44 | 45 | - name: Simulate Update NuGet packages to get JSON output 46 | run: dotnet outdated --output outdated.json --exclude FSharp.Core --exclude Microsoft.Extensions.Logging 47 | 48 | - name: Parse JSON and concatenate unique versions 49 | shell: pwsh 50 | run: | 51 | if (Test-Path 'outdated.json') { 52 | $json = Get-Content 'outdated.json' | ConvertFrom-Json 53 | $uniqueDeps = @{} 54 | foreach ($project in $json.Projects) { 55 | foreach ($framework in $project.TargetFrameworks) { 56 | foreach ($dep in $framework.Dependencies) { 57 | if (-not $uniqueDeps.ContainsKey($dep.Name)) { 58 | $uniqueDeps[$dep.Name] = "$($dep.Name) to $($dep.LatestVersion) (from $($dep.ResolvedVersion))" 59 | } 60 | } 61 | } 62 | } 63 | $result = $uniqueDeps.Values 64 | $concatenated = "Bump " + ($result -join '; ') 65 | echo "COMMIT_MSG=$concatenated" >> $env:GITHUB_ENV 66 | Remove-Item -Path outdated.json -Force 67 | } 68 | else { 69 | echo 'No outdated.json file found' 70 | echo "COMMIT_MSG=" >> $env:GITHUB_ENV 71 | } 72 | 73 | - name: Update NuGet packages and get Markdown output 74 | # if no updates are available, no output.json file is created 75 | if: ${{env.COMMIT_MSG }} 76 | run: dotnet outdated --upgrade --output outdated.md --output-format Markdown --exclude FSharp.Core --exclude Microsoft.Extensions.Logging 77 | 78 | - name: Read outdated.md file 79 | if: ${{env.COMMIT_MSG }} 80 | id: read-md 81 | uses: juliangruber/read-file-action@v1 82 | with: 83 | path: ./outdated.md 84 | 85 | - name: Delete outdated.md file 86 | if: ${{env.COMMIT_MSG }} 87 | shell: pwsh 88 | run: Remove-Item -Path outdated.md -Force 89 | 90 | # This will not create a duplicate PR if one exists already 91 | - name: Create Pull Request 92 | if: ${{env.COMMIT_MSG }} 93 | uses: peter-evans/create-pull-request@v7 94 | with: 95 | commit-message: ${{ env.COMMIT_MSG }} 96 | committer: github-actions[bot] 97 | author: dotnet-outdated[bot] 98 | branch: dotnet-outdated-bot 99 | delete-branch: true 100 | title: ${{ env.COMMIT_MSG }} 101 | body: ${{ steps.read-md.outputs.content }} 102 | labels: "dotnet-outdated" 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [Unreleased] 9 | ### Added 10 | - Nothing yet. 11 | 12 | ## [0.20.0] - 2025-03-19 13 | ### Changed 14 | - remove Microsoft.Extensions.Logging dependency and the ILogger interface implementation (to fix a method-not-found-exception in Velopack in Fesh.Revit) 15 | 16 | ## [0.19.0] - 2025-03-18 17 | ### Changed 18 | - remove explicit FSharp.Core ref 19 | 20 | ## [0.18.0] - 2025-02-15 21 | ### Added 22 | - ILogger interface 23 | 24 | ## [0.17.0] - 2024-11-19 25 | ### Added 26 | - Documentation via [FSharp.Formatting](https://fsprojects.github.io/FSharp.Formatting/) 27 | - Github Actions for CI/CD 28 | ### Changed 29 | - pin FSharp.Core to latest version (for Fesh.Revit) 30 | 31 | ## [0.16.0] - 2024-11-10 32 | ### Added 33 | - add .IsAlive property to turn on/off logging 34 | 35 | ## [0.15.8] - 2024-11-04 36 | ### Changed 37 | - upgrade to FSharp.Core 8.0.400 to make it work for Fesh.Revit 38 | 39 | ## [0.15.0] - 2024-11-03 40 | ### Added 41 | - Pen Utils 42 | - Exposed delay time configuration 43 | ### Changed 44 | - Faster redraw performance 45 | 46 | ## [0.14.0] - 2024-09-08 47 | ### Changed 48 | - Update to AvalonEditB 2.4.0 49 | 50 | ## [0.13.0] - 2024-06-09 51 | ### Changed 52 | - Update to AvalonEditB 2.3.0 53 | 54 | ## [0.12.0] - 2023-10-29 55 | ### Changed 56 | - Update to AvalonEditB 2.2.0 57 | 58 | ## [0.11.0] - 2023-09-10 59 | ### Changed 60 | - Disable replace in Log 61 | - Update to AvalonEditB 2.1.0 62 | 63 | ## [0.10.0] - 2023-07-27 64 | ### Changed 65 | - Update to AvalonEditB 2.0.0 66 | 67 | ## [0.9.1] - 2023-04-29 68 | ### Changed 69 | - Update to AvalonEditB 1.8.0 70 | 71 | ## [0.9.0] - 2023-04-23 72 | ### Changed 73 | - Update to AvalonEditB 1.7.0 74 | 75 | ## [0.8.3] - 2023-01-08 76 | ### Changed 77 | - Update to AvalonEditB 1.6.0 78 | 79 | ## [0.8.2] - 2022-12-17 80 | ### Changed 81 | - Target net7.0 82 | - Update to AvalonEditB 1.5.1 83 | - Update readme and fix typos 84 | 85 | ## [0.7.2] - 2022-08-06 86 | ### Fixed 87 | - Typos in readme 88 | 89 | ## [0.7.1] - 2022-08-06 90 | ### Changed 91 | - Use AvalonEditB `1.4.1` 92 | ### Fixed 93 | - Typos in doc-strings 94 | 95 | ## [0.7.0] - 2022-07-30 96 | ### Fixed 97 | - Crash when Log has more than 1000k characters 98 | 99 | ## [0.6.0] - 2022-01-09 100 | ### Fixed 101 | - ConditionalTextWriter 102 | 103 | ## [0.5.0] - 2021-12-11 104 | ### Changed 105 | - Update to AvalonEditB `1.3.0` 106 | - Target net6.0 and net472 107 | ### Fixed 108 | - Typos in docstring 109 | 110 | ## [0.4.0] - 2021-10-17 111 | ### Changed 112 | - Update to AvalonEditB `1.2.0` 113 | - Rename `GetTextWriterIf` to `GetConditionalTextWriter` 114 | 115 | ## [0.3.1] - 2021-09-26 116 | ### Changed 117 | - Update XML doc-strings 118 | 119 | 120 | 121 | [Unreleased]: https://github.com/goswinr/AvalonLog/compare/0.20.0...HEAD 122 | [0.20.0]: https://github.com/goswinr/AvalonLog/compare/0.19.0...0.20.0 123 | [0.19.0]: https://github.com/goswinr/AvalonLog/compare/0.18.0...0.19.0 124 | [0.18.0]: https://github.com/goswinr/AvalonLog/compare/0.17.0...0.18.0 125 | [0.17.0]: https://github.com/goswinr/AvalonLog/compare/0.16.0...0.17.0 126 | [0.16.0]: https://github.com/goswinr/AvalonLog/compare/0.15.8...0.16.0 127 | [0.15.8]: https://github.com/goswinr/AvalonLog/compare/0.15.0...0.15.8 128 | [0.15.0]: https://github.com/goswinr/AvalonLog/compare/0.14.0...0.15.0 129 | [0.14.0]: https://github.com/goswinr/AvalonLog/compare/0.13.0...0.14.0 130 | [0.13.0]: https://github.com/goswinr/AvalonLog/compare/0.12.0...0.13.0 131 | [0.12.0]: https://github.com/goswinr/AvalonLog/compare/0.11.0...0.12.0 132 | [0.11.0]: https://github.com/goswinr/AvalonLog/compare/0.10.0...0.11.0 133 | [0.10.0]: https://github.com/goswinr/AvalonLog/compare/0.9.3...0.10.0 134 | [0.9.3]: https://github.com/goswinr/AvalonLog/compare/0.9.2...0.9.3 135 | [0.9.2]: https://github.com/goswinr/AvalonLog/compare/0.9.1...0.9.2 136 | [0.9.1]: https://github.com/goswinr/AvalonLog/compare/0.9.0...0.9.1 137 | [0.9.0]: https://github.com/goswinr/AvalonLog/compare/0.8.3...0.9.0 138 | [0.8.3]: https://github.com/goswinr/AvalonLog/compare/0.8.2...0.8.3 139 | [0.8.2]: https://github.com/goswinr/AvalonLog/compare/0.7.2...0.8.2 140 | [0.7.2]: https://github.com/goswinr/AvalonLog/compare/0.7.1...0.7.2 141 | [0.7.1]: https://github.com/goswinr/AvalonLog/compare/0.7.0...0.7.1 142 | [0.7.0]: https://github.com/goswinr/AvalonLog/compare/0.6.0...0.7.0 143 | [0.6.0]: https://github.com/goswinr/AvalonLog/compare/0.5.0...0.6.0 144 | [0.5.0]: https://github.com/goswinr/AvalonLog/compare/0.4.0...0.5.0 145 | [0.4.0]: https://github.com/goswinr/AvalonLog/compare/0.3.1...0.4.0 146 | [0.3.1]: https://github.com/goswinr/AvalonLog/releases/tag/0.3.1 147 | -------------------------------------------------------------------------------- /Src/AvalonLog.fsproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Library 5 | net472;net7.0-windows 6 | true 7 | preview 8 | 9 | en 10 | en 11 | true 12 | 13 | 15 | true 16 | 17 | AvalonLog 18 | AvalonLog 19 | AvalonLog 20 | AvalonLog 21 | AvalonLog 22 | AvalonLog 23 | 24 | 25 | 26 | GoswinR 27 | Goswin Rothenthal 2021 28 | 29 | A fast and thread-safe WPF text viewer for colored text. Including F# printf formatting. Based on AvalonEdit 30 | A fast and thread-safe WPF text viewer for colored text. Including F# printf formatting. Based on AvalonEdit 31 | 32 | 33 | $(OtherFlags)--warnon:3390 34 | $(OtherFlags) --warnon:1182 35 | 36 | 37 | 38 | 39 | wpf;console;fsharp;avalonedit 40 | 41 | true 42 | true 43 | 44 | true 45 | git 46 | MIT 47 | true 48 | logo128.png 49 | README.md 50 | embedded 51 | true 52 | 53 | https://github.com/goswinr/AvalonLog 54 | https://github.com/goswinr/AvalonLog/blob/main/LICENSE.md 55 | https://github.com/goswinr/AvalonLog/blob/main/CHANGELOG.md 56 | https://goswinr.github.io/AvalonLog 57 | 58 | img/favicon.ico 59 | true 60 | ../CHANGELOG.md 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | -------------------------------------------------------------------------------- /Src/TextColor.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | open AvalonLog.Util 4 | open System 5 | open AvalonEditB 6 | open System.Windows.Media // for color brushes 7 | 8 | 9 | /// Describes the position in text where a new color starts 10 | [] 11 | [] 12 | type internal NewColor = 13 | { 14 | off : int 15 | brush : SolidColorBrush // brush must be frozen to be used async 16 | } 17 | 18 | /// Does binary search to find an offset that is equal or smaller than currOff 19 | static member findCurrentInList (cs:ResizeArray) currOff :NewColor = 20 | // cs has a least one item that is {off = -1 ; brush=null}, set in AvalonLog constructor 21 | 22 | let last = cs.Count-1 //TODO: is it possible that count increases while iterating? 23 | let rec find lo hi = 24 | let mid = lo + (hi - lo) / 2 //TODO test edge conditions !! 25 | if cs.[mid].off <= currOff then 26 | if mid = last then cs.[mid] // exit 27 | elif cs.[mid+1].off > currOff then cs.[mid] // exit 28 | else find (mid+1) hi 29 | else 30 | find lo (mid-1) 31 | find 0 last 32 | 33 | /// Describes the start and end position of a color with one line 34 | [] 35 | [] 36 | type internal RangeColor = 37 | { 38 | start :int 39 | ende :int 40 | brush :SolidColorBrush // brush must be frozen to be used async 41 | } 42 | 43 | /// Finds all the offset that apply to this line which is defined by the range of tOff to enOff 44 | /// even if the ResizeArray does not contain any offset between stOff and enOff 45 | /// it still returns the a list with one item. The closest previous offset 46 | static member getInRange (cs:ResizeArray) stOff enOff = 47 | let rec mkList i ls = 48 | let c = NewColor.findCurrentInList cs i 49 | if c.off <= stOff then 50 | {start = stOff ; ende = enOff ; brush = c.brush} :: ls 51 | else 52 | mkList (i-1) ({start = i; ende = enOff ; brush = c.brush} :: ls) 53 | mkList enOff [] 54 | 55 | 56 | /// To implement the actual colors from colored printing 57 | type internal ColorizingTransformer(ed:TextEditor, offsetColors: ResizeArray, defaultBrush) = 58 | inherit Rendering.DocumentColorizingTransformer() 59 | 60 | let mutable selStart = -9 61 | let mutable selEnd = -9 62 | let mutable any = false 63 | 64 | member _.SelectionChangedDelegate (_:EventArgs) = 65 | if ed.SelectionLength = 0 then // no selection 66 | selStart <- -9 67 | selEnd <- -9 68 | else 69 | selStart <- ed.SelectionStart 70 | selEnd <- selStart + ed.SelectionLength // this is the last selection in case of block selection too ! correct 71 | 72 | 73 | /// This gets called for every visible line on any view change 74 | override this.ColorizeLine(line:Document.DocumentLine) = 75 | if not line.IsDeleted then 76 | let stLn = line.Offset 77 | let enLn = line.EndOffset 78 | let cs = RangeColor.getInRange offsetColors stLn enLn 79 | any <- false 80 | 81 | // color non selected lines 82 | if selStart = selEnd || selStart > enLn || selEnd < stLn then// no selection in general or on this line 83 | for c in cs do 84 | if c.brush = null && any then //changing the base-foreground is only needed if any other color already exists on this line 85 | base.ChangeLinePart(c.start, c.ende, fun element -> element.TextRunProperties.SetForegroundBrush(defaultBrush)) 86 | else 87 | if notNull c.brush then // might still happen on first line 88 | any <-true 89 | base.ChangeLinePart(c.start, c.ende, fun el -> el.TextRunProperties.SetForegroundBrush(c.brush)) 90 | 91 | /// exclude selection from coloring: 92 | else 93 | for c in cs do 94 | let br = if isNull c.brush then defaultBrush else c.brush 95 | let st = c.start 96 | let en = c.ende 97 | // now consider block or rectangle selection: 98 | for seg in ed.TextArea.Selection.Segments do 99 | if seg.EndOffset < stLn then () // this segment is on another line 100 | elif seg.StartOffset > enLn then () // this segment is on another line 101 | else 102 | if seg.StartOffset = seg.EndOffset then base.ChangeLinePart(st, en, fun el -> el.TextRunProperties.SetForegroundBrush(br)) // the selection segment is after the line end, this might happen in block selection 103 | elif seg.StartOffset > en then base.ChangeLinePart(st, en, fun el -> el.TextRunProperties.SetForegroundBrush(br)) // the selection segment comes after this color section 104 | elif seg.EndOffset <= st then base.ChangeLinePart(st, en, fun el -> el.TextRunProperties.SetForegroundBrush(br)) // the selection segment comes before this color section 105 | else 106 | if st < seg.StartOffset then base.ChangeLinePart(st , seg.StartOffset, fun el -> el.TextRunProperties.SetForegroundBrush(br)) 107 | if en > seg.EndOffset then base.ChangeLinePart(seg.EndOffset, en , fun el -> el.TextRunProperties.SetForegroundBrush(br)) 108 | 109 | -------------------------------------------------------------------------------- /Src/SelectedTextHighlighter.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | open System 4 | open System.Windows.Media // for color brushes 5 | open AvalonEditB 6 | open AvalonLog.Util 7 | 8 | /// Highlight-all-occurrences-of-selected-text in Log Text View 9 | /// if the selection is more then two non-whitespace characters. 10 | type SelectedTextHighlighter (lg:TextEditor) = 11 | inherit Rendering.DocumentColorizingTransformer() 12 | // based on https://stackoverflow.com/questions/9223674/highlight-all-occurrences-of-selected-word-in-avalonedit 13 | 14 | let mutable isEnabled = true 15 | 16 | let mutable highTxt = null // will be set if there is a text to highlight 17 | 18 | let mutable curSelStart = -1 19 | let mutable curSelEnd = -1 // end is the last character with highlighting 20 | 21 | let mutable colorHighlight = Brushes.Blue |> Brush.brighter 210 |> Brush.freeze 22 | 23 | // Events for a status bar or other UI 24 | let highlightClearedEv = new Event() 25 | let highlightChangedEv = new Event>() 26 | 27 | let selectionChanged () = 28 | if isEnabled then 29 | let selTxt = 30 | let sel = lg.TextArea.Selection 31 | if sel.Length < 2 then "" //only highlight if 2 or more characters selected 32 | elif sel.StartPosition.Line <> sel.EndPosition.Line then "" //only highlight if one-line-selection 33 | else 34 | let selt = lg.SelectedText //sel.GetText() // for block selection this will contain everything from first segment till last segment, even the unselected. 35 | if selt.Trim().Length < 2 then ""// minimum 2 non whitespace characters? 36 | else selt 37 | 38 | if selTxt <>"" then 39 | // for current text view: 40 | highTxt <- selTxt 41 | curSelStart <- lg.SelectionStart 42 | curSelEnd <- curSelStart + selTxt.Length - 1 43 | lg.TextArea.TextView.Redraw() // this triggers ColorizeLine on every visible line. 44 | 45 | // for events to complete count in full document : 46 | let doc = lg.Document // get doc in sync first ! 47 | async{ 48 | // get and search text in background thread to get total count too: 49 | // then raise event in sync with this info: 50 | let tx = doc.CreateSnapshot().Text 51 | let locations = ResizeArray() 52 | let mutable index = tx.IndexOf(selTxt, 0, StringComparison.Ordinal) 53 | while index >= 0 do 54 | locations.Add(index) 55 | let st = index + selTxt.Length 56 | if st >= tx.Length then 57 | index <- -99 58 | else 59 | index <- tx.IndexOf(selTxt, st, StringComparison.Ordinal) 60 | 61 | do! Async.SwitchToContext SyncAvalonLog.context 62 | highlightChangedEv.Trigger(selTxt, locations) // to update status bar or similar UI 63 | } |> Async.Start 64 | 65 | else 66 | if notNull highTxt then // to only redraw if it was not null before but should be null now because selTxt="" 67 | highTxt <- null 68 | lg.TextArea.TextView.Redraw() // to clear highlight 69 | highlightClearedEv.Trigger() 70 | 71 | 72 | /// Occurs when the selection clears or is less than two non-whitespace Characters. 73 | [] 74 | member _.OnHighlightCleared = highlightClearedEv.Publish 75 | 76 | /// Occurs when the selection changes to more than two non-whitespace Characters. 77 | /// Returns tuple of selected text and list of all start offsets in full text. (including invisible ones) 78 | [] 79 | member _.OnHighlightChanged = highlightChangedEv.Publish 80 | 81 | /// The color used for highlighting other occurrences of the selected text. 82 | member _.ColorHighlighting 83 | with get () = colorHighlight 84 | and set v = colorHighlight <- Brush.freeze v 85 | 86 | /// To enable or disable this highlighter, it is enabled by default. 87 | member _.IsEnabled 88 | with get () = isEnabled 89 | and set on = 90 | isEnabled <- on 91 | if on then selectionChanged() 92 | elif notNull highTxt then // now off, clear highlight if any 93 | lg.TextArea.TextView.Redraw() // to clear highlight 94 | highlightClearedEv.Trigger() //there was a highlight before thats off now 95 | 96 | 97 | /// The main override for DocumentColorizingTransformer. 98 | /// This gets called for every visible line on any view change. 99 | override _.ColorizeLine(line:Document.DocumentLine) = 100 | if isEnabled && notNull highTxt then 101 | let lineStartOffset = line.Offset; 102 | let text = lg.Document.GetText(line) 103 | let mutable index = text.IndexOf(highTxt, 0, StringComparison.Ordinal) 104 | while index >= 0 do 105 | let st = lineStartOffset + index // startOffset 106 | let en = lineStartOffset + index + highTxt.Length - 1 // end offset is the last character with highlighting 107 | if (st < curSelStart || st > curSelEnd) && (en < curSelStart || en > curSelEnd ) then // skip the actual current selection 108 | // here end offset needs + 1 to be the first character without highlighting 109 | base.ChangeLinePart( st, en + 1, fun el -> el.TextRunProperties.SetBackgroundBrush(colorHighlight)) 110 | let start = index + highTxt.Length // search for next occurrence // TODO or just +1 ??????? 111 | index <- text.IndexOf(highTxt, start, StringComparison.Ordinal) 112 | 113 | 114 | member _.SelectionChangedDelegate (_:EventArgs) = 115 | selectionChanged() 116 | 117 | -------------------------------------------------------------------------------- /Src/AvalonLog.fs: -------------------------------------------------------------------------------- 1 | namespace AvalonLog 2 | 3 | open AvalonLog.Util 4 | open AvalonLog.Brush 5 | open System 6 | open System.IO 7 | open System.Threading 8 | open AvalonEditB 9 | open System.Windows.Media // for color brushes 10 | open System.Text 11 | open System.Diagnostics 12 | open System.Windows.Controls 13 | open AvalonEditB.Document 14 | 15 | 16 | /// A ReadOnly text AvalonEdit Editor that provides colored appending via printfn like functions. 17 | /// Use the hidden member AvalonEdit if you need to access the underlying TextEditor class from AvalonEdit for styling. 18 | /// Don't append or change the AvalonEdit.Text property directly. This will mess up the coloring. 19 | /// Only use the printfn and Append functions of this class. 20 | type AvalonLog () = 21 | inherit ContentControl() // the most simple and generic type of UIelement container, like a
in html 22 | 23 | /// Stores all the locations where a new color starts. 24 | /// Will be searched via binary search in colorizing transformers 25 | let offsetColors = ResizeArray( [ {off = -1 ; brush=null} ] ) // null is console out // null check done in this.ColorizeLine(line:AvalonEdit.Document.DocumentLine) .. 26 | 27 | 28 | /// Same as default foreground in underlying AvalonEdit. 29 | /// Will be changed if AvalonEdit foreground brush changes 30 | let mutable defaultBrush = Brushes.Black |> freeze // should be same as default foreground. Will be set on foreground color changes 31 | 32 | /// Used for printing with custom rgb values 33 | let mutable customBrush = Brushes.Black |> freeze // will be changed anyway on first call 34 | 35 | let setCustomBrush(red,green,blue) = 36 | let r = clampToByte red 37 | let g = clampToByte green 38 | let b = clampToByte blue 39 | let col = customBrush.Color 40 | if col.R <> r || col.G <> g || col.B <> b then // only change if different 41 | customBrush <- freeze (new SolidColorBrush(Color.FromRgb(r,g,b))) 42 | 43 | let log = new TextEditor() 44 | let hiLi = new SelectedTextHighlighter(log) 45 | let color = new ColorizingTransformer(log, offsetColors, defaultBrush) 46 | 47 | let searchPanel = Search.SearchPanel.Install(log, enableReplace = false) // disable replace via search replace dialog 48 | 49 | let mutable isAlive = true 50 | 51 | do 52 | base.Content <- log //nest Avalonedit inside a simple ContentControl to hide most of its functionality 53 | 54 | log.FontFamily <- FontFamily("Cascadia Code") // default font 55 | log.FontSize <- 14.0 56 | log.IsReadOnly <- true 57 | log.Encoding <- Text.Encoding.Default // = UTF-16 58 | log.ShowLineNumbers <- true 59 | log.Options.EnableHyperlinks <- true 60 | log.TextArea.SelectionCornerRadius <- 0.0 61 | log.TextArea.SelectionBorder <- null 62 | log.TextArea.TextView.LinkTextForegroundBrush <- Brushes.Blue |> Brush.freeze //Hyper-links color 63 | 64 | log.TextArea.TextView.LineTransformers.Add(color) // to actually draw colored text 65 | log.TextArea.SelectionChanged.Add color.SelectionChangedDelegate // to exclude selected text from being colored 66 | 67 | // to highlight all instances of the selected word 68 | log.TextArea.TextView.LineTransformers.Add(hiLi) 69 | log.TextArea.SelectionChanged.Add hiLi.SelectionChangedDelegate 70 | 71 | match log.TextArea.LeftMargins.[0] with // the line number margin 72 | | :? Editing.LineNumberMargin as lm -> lm.HighlightCurrentLineNumber <- false // disable highlighting of current line number 73 | | _ -> () 74 | 75 | defaultBrush <- (log.Foreground.Clone() :?> SolidColorBrush |> Brush.freeze) // just to be sure they are the same 76 | //log.Foreground.Changed.Add ( fun _ -> LogColors.consoleOut <- (log.Foreground.Clone() :?> SolidColorBrush |> freeze)) // this event attaching can't be done because it is already frozen 77 | 78 | let printCallsCounter = ref 0L 79 | let mutable prevMsgBrush = null //null is no color for console // null check done in this.ColorizeLine(line:AvalonEdit.Document.DocumentLine) .. 80 | let stopWatch = Stopwatch.StartNew() 81 | let buffer = new StringBuilder() 82 | let mutable docLength = 0 //to be able to have the doc length async 83 | let mutable maxCharsInLog = 1024_000 // about 10k lines with 100 chars each 84 | let mutable stillLessThanMaxChars = true 85 | let mutable dontPrintJustBuffer = false // for use in this.Clear() to make sure a print after a clear does not get swallowed 86 | 87 | let mutable printInterval : int64 = 50L //100L 88 | 89 | let mutable lastPrintDelay : int = 30 //70 90 | 91 | //----------------------------------------------------------------------------------- 92 | // The below functions are trying to work around double UI update in printfn for better UI performance, 93 | // and the poor performance of log.ScrollToEnd(). 94 | // https://github.com/dotnet/fsharp/issues/3712 95 | // https://github.com/icsharpcode/AvalonEdit/issues/226 96 | //----------------------------------------------------------------------------------- 97 | 98 | let getBufferText () = 99 | let txt = buffer.ToString() 100 | buffer.Clear() |> ignore 101 | txt 102 | 103 | /// must be called in sync 104 | let printToLog() = 105 | let txt = lock buffer getBufferText //lock for safe access 106 | if txt.Length > 0 then //might be empty from calls during don't PrintJustBuffer = true 107 | log.AppendText(txt) // TODO is it possible that avalonedit skips adding some escape ANSI characters to document?? then docLength could be out of sync !! TODO 108 | log.ScrollToEnd() 109 | if log.WordWrap then log.ScrollToEnd() //this is needed a second time. see https://github.com/dotnet/fsharp/issues/3712 110 | stopWatch.Restart() 111 | 112 | let newLine = Environment.NewLine 113 | 114 | // let debugFile = 115 | // Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Desktop), "AvalonLogDebug.txt") 116 | // |> fun p -> IO.File.AppendAllText(p, "AvalonLogDebug.txt created" + Environment.NewLine); p 117 | 118 | 119 | /// Adds string on UI thread every 150ms then scrolls to end after 300ms. 120 | /// Optionally adds new line at end. 121 | /// Sets line color on LineColors dictionary for DocumentColorizingTransformer. 122 | /// printOrBuffer (txt:string, addNewLine:bool, typ:SolidColorBrush) 123 | let printOrBuffer (txt:string, addNewLine:bool, brush:SolidColorBrush) = // TODO check for escape sequence characters and don't print or count them, how many are skipped by ava-edit during Text.Append?? 124 | // IO.File.AppendAllText(debugFile, txt + (if addNewLine then Environment.NewLine else "") + "£") 125 | if stillLessThanMaxChars && (txt.Length <> 0 || addNewLine) && isAlive then 126 | lock buffer (fun () -> // or rwl.EnterWriteLock() //https://stackoverflow.com/questions/23661863/f-synchronized-access-to-list 127 | // Change color if needed: 128 | if prevMsgBrush <> brush then 129 | offsetColors.Add { off = docLength; brush = brush } // TODO filter out ANSI escape chars first or just keep them in the doc but not in the visual line ?? 130 | prevMsgBrush <- brush 131 | 132 | // add to buffer 133 | if addNewLine then 134 | buffer.AppendLine(txt) |> ignore 135 | docLength <- docLength + txt.Length + newLine.Length 136 | else 137 | buffer.Append(txt) |> ignore 138 | docLength <- docLength + txt.Length 139 | ) 140 | 141 | // check if total text in log is already to big , print it and then stop printing 142 | if docLength > maxCharsInLog && isAlive then // needed when log gets piled up with exception messages form Avalonedit rendering pipeline. 143 | stillLessThanMaxChars <- false 144 | log.Dispatcher.Invoke(printToLog) 145 | let itsOverTxt = sprintf "%s%s **** STOP OF LOGGING **** Log has more than %d characters! Clear Log view first %s%s%s%s " newLine newLine maxCharsInLog newLine newLine newLine newLine 146 | lock buffer (fun () -> 147 | offsetColors.Add { off = docLength; brush = Brushes.Red |> freeze} 148 | buffer.AppendLine(itsOverTxt) |> ignore 149 | docLength <- docLength + itsOverTxt.Length 150 | ) 151 | log.Dispatcher.Invoke(printToLog) 152 | 153 | //previous version: (suffers from race condition where Async.SwitchToContext SyncAvalonLog.context does not work) 154 | //async { 155 | // do! Async.SwitchToContext SyncAvalonLog.context 156 | // printToLog()// runs with a lock too 157 | // log.AppendText(sprintf "%s%s *** STOP OF LOGGING *** Log has more than %d characters! clear Log view first" newLine newLine maxCharsInLog) 158 | // log.ScrollToEnd() 159 | // log.ScrollToEnd() // call twice because of https://github.com/icsharpcode/AvalonEdit/issues/226 160 | // } |> Async.StartImmediate 161 | 162 | // check if we are in the process of clearing the view 163 | elif dontPrintJustBuffer then // wait really long before printing 164 | async { 165 | let k = Interlocked.Increment printCallsCounter 166 | do! Async.Sleep 50 167 | while dontPrintJustBuffer do // wait till don't PrintJustBuffer is set true from end of this.Clear() call 168 | do! Async.Sleep 50 169 | if printCallsCounter.Value = k && isAlive then //it is the last call for 100 ms 170 | // on why using Invoke: https://stackoverflow.com/a/19009579/969070 171 | log.Dispatcher.Invoke(printToLog) 172 | } |> Async.StartImmediate 173 | 174 | // normal case: 175 | else 176 | // check the two criteria for actually printing 177 | // PRINT CASE 1: since the last printing call more than 100 ms have elapsed. this case is used if a lot of print calls arrive at the log for a more than printInterval (100 ms.) 178 | // PRINT CASE 2, wait 70 ms and print if nothing else has been added to the buffer during the last lastPrintDelay (70 ms) 179 | 180 | if stopWatch.ElapsedMilliseconds > printInterval && isAlive then // PRINT CASE 1: only add to document every printInterval (100ms) 181 | // printToLog() will also reset stopwatch. 182 | // on why using Invoke: https://stackoverflow.com/a/19009579/969070 183 | log.Dispatcher.Invoke( printToLog) // TODO a bit faster probably and less verbose than async here but would this propagate exceptions too ? 184 | 185 | //previous version: 186 | //async { 187 | // do! Async.SwitchToContext SyncAvalonLog.context // slower to start but this would this propagate exceptions too ? 188 | // printToLog() // runs with a lock too 189 | // } |> Async.StartImmediate 190 | 191 | else 192 | /// do timing as low level as possible: see Async.Sleep in https://github.com/dotnet/fsharp/blob/main/src/fsharp/FSharp.Core/async.fs#L1587 193 | let mutable timer :option = None 194 | let k = Interlocked.Increment printCallsCounter 195 | let action = TimerCallback(fun _ -> 196 | if printCallsCounter.Value = k && isAlive then //PRINT CASE 2, it is the last call for 70 ms, there has been no other Increment to printCallsCounter 197 | log.Dispatcher.Invoke(printToLog) // without isAlive check this can throw a TaskCanceledException while some errors print to stdout during host shutdown (Fesh.Revit 2025) 198 | if timer.IsSome then 199 | timer.Value.Dispose() // dispose inside callback, like in Async.Sleep in FSharp.Core 200 | ) 201 | timer <- Some (new Threading.Timer(action, null, dueTime = lastPrintDelay , period = -1)) 202 | 203 | // previous version: 204 | //async { 205 | // let k = Interlocked.Increment printCallsCounter 206 | // do! Async.Sleep 100 207 | // if !printCallsCounter = k then //PRINT CASE 2, it is the last call for 100 ms 208 | // log.Dispatcher.Invoke(printToLog) 209 | // } |> Async.StartImmediate 210 | 211 | let print (br:SolidColorBrush, s) = 212 | customBrush <- br 213 | printOrBuffer (s, true, customBrush) 214 | 215 | 216 | 217 | 218 | //----------------------------------------------------------- 219 | //----------------------exposed AvalonEdit members:---------- 220 | //----------------------------------------------------------- 221 | 222 | /// if not alive all calls to Dispatcher.Invoke will be cancelled 223 | /// because they can throw a TaskCanceledException while some errors print to stdout during host shutdown (Fesh.Revit 2025) 224 | member _.IsAlive 225 | with get() = isAlive 226 | and set v = isAlive <- v 227 | 228 | 229 | member _.VerticalScrollBarVisibility with get() = log.VerticalScrollBarVisibility and set v = log.VerticalScrollBarVisibility <- v 230 | member _.HorizontalScrollBarVisibility with get() = log.HorizontalScrollBarVisibility and set v = log.HorizontalScrollBarVisibility <- v 231 | member _.FontFamily with get() = log.FontFamily and set v = log.FontFamily <- v 232 | member _.FontSize with get() = log.FontSize and set v = log.FontSize <- v 233 | //member _.Encoding with get() = log.Encoding and set v = log.Encoding <- v 234 | member _.ShowLineNumbers with get() = log.ShowLineNumbers and set v = log.ShowLineNumbers <- v 235 | member _.EnableHyperlinks with get() = log.Options.EnableHyperlinks and set v = log.Options.EnableHyperlinks <- v 236 | 237 | /// the delay in milliseconds after the last print call before the log is printed to the screen 238 | /// should be less than the printInterval 239 | member _.LastPrintDelay 240 | with get() = lastPrintDelay 241 | and set v = lastPrintDelay <- v 242 | 243 | /// the time in milliseconds between two print calls to the log 244 | /// any print calls arriving during this time will be buffered and printed in one go after the printInterval 245 | member _.PrintInterval 246 | with get() = printInterval 247 | and set v = printInterval <- v 248 | 249 | /// Get all text in this AvalonLog 250 | member _.Text() = log.Text 251 | 252 | /// Get all text in Segment AvalonLog 253 | member _.Text(seg:ISegment) = log.Document.GetText(seg) 254 | 255 | /// Get the current Selection 256 | member _.Selection = log.TextArea.Selection 257 | 258 | /// The SearchPanel from AvalonEditB 259 | member _.SearchPanel = searchPanel 260 | 261 | /// Use true to enable Line Wrap. 262 | /// setting false will enable Horizontal ScrollBar Visibility 263 | /// setting true will disable Horizontal ScrollBar Visibility 264 | member _.WordWrap 265 | with get() = log.WordWrap 266 | and set v = 267 | if v then 268 | log.WordWrap <- true 269 | log.HorizontalScrollBarVisibility <- ScrollBarVisibility.Disabled 270 | else 271 | log.WordWrap <- false 272 | log.HorizontalScrollBarVisibility <- ScrollBarVisibility.Auto 273 | 274 | //----------------------------------------------------------- 275 | //----------------------AvalonLog specific members:---------- 276 | //------------------------------------------------------------ 277 | 278 | /// The maximum amount of characters this AvalonLog can display. 279 | /// By default this about one Million characters 280 | /// This is to avoid freezing the UI when the AvalonLog is flooded with text. 281 | /// When the maximum is reached a message will be printed at the end, then the printing stops until the content is cleared. 282 | member _.MaximumCharacterAllowance 283 | with get () = maxCharsInLog 284 | and set v = maxCharsInLog <- v 285 | 286 | 287 | /// To access the underlying AvalonEdit TextEditor class 288 | /// Don't append , clear or modify the Text property directly! 289 | /// This will mess up the coloring. 290 | /// Only use the printfn family of functions to add text to AvalonLog 291 | /// Use this member only for styling changes 292 | /// use #nowarn "44" to disable the obsolete warning 293 | [