From 614b343dde25275f7a20e799437c40892fdc5f06 Mon Sep 17 00:00:00 2001 From: Rory Linehan Date: Thu, 11 Jun 2026 20:44:35 -0500 Subject: [PATCH 1/3] adding native UnicodePlots rendering --- Manifest.toml | 248 +++++++++++++++++++++++- Project.toml | 4 +- docs/src/usage.md | 46 +++++ examples/README.md | 5 + examples/unicodeplot.jl | 43 +++++ src/TerminalUserInterfaces.jl | 2 + src/app.jl | 13 +- src/event.jl | 17 ++ src/widgets/layout.jl | 25 +++ src/widgets/plot.jl | 347 ++++++++++++++++++++++++++++++++++ src/widgets/widgets.jl | 6 + 11 files changed, 745 insertions(+), 11 deletions(-) create mode 100644 examples/unicodeplot.jl create mode 100644 src/widgets/plot.jl diff --git a/Manifest.toml b/Manifest.toml index 7a57419..fc3e956 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -1,14 +1,56 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.9.3" +julia_version = "1.12.6" manifest_format = "2.0" -project_hash = "a37a30fc55b25965e75590eb0decae443a16c899" +project_hash = "8922ac694c8a072f320392821e96b3b21ab34ad0" + +[[deps.AliasTables]] +deps = ["PtrArrays", "Random"] +git-tree-sha1 = "9876e1e164b144ca45e9e3198d0b689cadfed9ff" +uuid = "66dad0bd-aa9a-41b7-9441-69ab47430ed8" +version = "1.1.3" [[deps.Artifacts]] uuid = "56f22d72-fd6d-98f1-02f0-08ddc0907c33" +version = "1.11.0" [[deps.Base64]] uuid = "2a0f44e3-6c83-55bd-87e4-b1978d98bd5f" +version = "1.11.0" + +[[deps.ColorSchemes]] +deps = ["ColorTypes", "ColorVectorSpace", "Colors", "FixedPointNumbers", "PrecompileTools", "Random"] +git-tree-sha1 = "b0fd3f56fa442f81e0a47815c92245acfaaa4e34" +uuid = "35d6a980-a343-548e-a6ea-1d62b119f2f4" +version = "3.31.0" + +[[deps.ColorTypes]] +deps = ["FixedPointNumbers", "Random"] +git-tree-sha1 = "67e11ee83a43eb71ddc950302c53bf33f0690dfe" +uuid = "3da002f7-5984-5a60-b8a6-cbb66c0b333f" +version = "0.12.1" +weakdeps = ["StyledStrings"] + + [deps.ColorTypes.extensions] + StyledStringsExt = "StyledStrings" + +[[deps.ColorVectorSpace]] +deps = ["ColorTypes", "FixedPointNumbers", "LinearAlgebra", "Requires", "Statistics", "TensorCore"] +git-tree-sha1 = "8b3b6f87ce8f65a2b4f857528fd8d70086cd72b1" +uuid = "c3611d14-8923-5661-9e6a-0046d554d3a4" +version = "0.11.0" + + [deps.ColorVectorSpace.extensions] + SpecialFunctionsExt = "SpecialFunctions" + + [deps.ColorVectorSpace.weakdeps] + SpecialFunctions = "276daf66-3868-5448-9aa4-cd146d93841b" + +[[deps.Colors]] +deps = ["ColorTypes", "FixedPointNumbers", "Reexport"] +git-tree-sha1 = "37ea44092930b1811e666c3bc38065d7d87fcc74" +uuid = "5ae59095-9a9b-59fe-a467-6f913c188581" +version = "0.13.1" [[deps.Compat]] deps = ["TOML", "UUIDs"] @@ -23,7 +65,12 @@ weakdeps = ["Dates", "LinearAlgebra"] [[deps.CompilerSupportLibraries_jll]] deps = ["Artifacts", "Libdl"] uuid = "e66e0078-7015-5450-92f7-15fbd957f2ae" -version = "1.0.5+0" +version = "1.3.0+1" + +[[deps.Contour]] +git-tree-sha1 = "439e35b0b36e2e5881738abc8857bd92ad6ff9a8" +uuid = "d38c429a-6771-53c6-b99e-75d170b6e991" +version = "0.6.3" [[deps.Crayons]] git-tree-sha1 = "249fe38abf76d48563e2f4556bebd215aa317e15" @@ -55,12 +102,24 @@ version = "1.0.0" [[deps.Dates]] deps = ["Printf"] uuid = "ade2ca70-3891-5945-98fb-dc099432e06a" +version = "1.11.0" + +[[deps.DocStringExtensions]] +git-tree-sha1 = "7442a5dfe1ebb773c29cc2962a8980f47221d76c" +uuid = "ffbed154-4ef7-542d-bbb7-c09d3a79fcae" +version = "0.9.5" [[deps.EnumX]] git-tree-sha1 = "bdb1942cd4c45e3c678fd11569d5cccd80976237" uuid = "4e289a0a-7415-4d19-859d-a7e5c4648b56" version = "1.0.4" +[[deps.FixedPointNumbers]] +deps = ["Random", "Statistics"] +git-tree-sha1 = "59af96b98217c6ef4ae0dfe065ac7c20831d1a84" +uuid = "53c48c17-4a7d-5ca2-90c5-79b7896eea93" +version = "0.8.6" + [[deps.InlineTest]] deps = ["Test"] git-tree-sha1 = "daf0743879904f0ad645ca6594e1479685f158a2" @@ -70,6 +129,12 @@ version = "0.2.0" [[deps.InteractiveUtils]] deps = ["Markdown"] uuid = "b77e0a4c-d291-57a0-90e8-8db25a27a240" +version = "1.11.0" + +[[deps.IrrationalConstants]] +git-tree-sha1 = "b2d91fe939cae05960e760110b328288867b5758" +uuid = "92d709cd-6900-40b7-9082-c6be49f344b6" +version = "0.2.6" [[deps.IteratorInterfaceExtensions]] git-tree-sha1 = "a3f24677c21f5bbe9d2a714f95dcd58337fb2856" @@ -94,6 +159,11 @@ version = "1.14.0" [deps.JSON3.weakdeps] ArrowTypes = "31f734f8-188a-4ce0-8406-c8a06bd891cd" +[[deps.JuliaSyntaxHighlighting]] +deps = ["StyledStrings"] +uuid = "ac6e5ff7-fb65-4e79-a425-ec3bc9c03011" +version = "1.12.0" + [[deps.KiwiConstraintSolver]] deps = ["DataStructures", "InlineTest"] git-tree-sha1 = "7769c5e0990228c2b761a8209fabe6d9e8096340" @@ -102,13 +172,32 @@ version = "1.1.1" [[deps.Libdl]] uuid = "8f399da3-3557-5675-b5ff-fb832c97cbdb" +version = "1.11.0" [[deps.LinearAlgebra]] deps = ["Libdl", "OpenBLAS_jll", "libblastrampoline_jll"] uuid = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" +version = "1.12.0" + +[[deps.LogExpFunctions]] +deps = ["DocStringExtensions", "IrrationalConstants", "LinearAlgebra"] +git-tree-sha1 = "bba2d9aa057d8f126415de240573e86a8f39d2a1" +uuid = "2ab3a3ac-af41-5b50-aa03-7779005ae688" +version = "1.0.1" + + [deps.LogExpFunctions.extensions] + LogExpFunctionsChainRulesCoreExt = "ChainRulesCore" + LogExpFunctionsChangesOfVariablesExt = "ChangesOfVariables" + LogExpFunctionsInverseFunctionsExt = "InverseFunctions" + + [deps.LogExpFunctions.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + ChangesOfVariables = "9e997f8a-9a97-42d5-a9f1-ce6bfc15e2c0" + InverseFunctions = "3587e190-3f89-42d0-90ee-14403ec27112" [[deps.Logging]] uuid = "56ddb016-857b-54e1-b83d-db4d58db5568" +version = "1.11.0" [[deps.LoggingExtras]] deps = ["Dates", "Logging"] @@ -122,17 +211,42 @@ git-tree-sha1 = "68094dff732670e3a466eaede84df80521298c83" uuid = "98105f81-4425-4516-93fd-1664fb551ab6" version = "1.2.0" +[[deps.MarchingCubes]] +deps = ["PrecompileTools", "StaticArrays"] +git-tree-sha1 = "0e893025924b6becbae4109f8020ac0e12674b01" +uuid = "299715c1-40a9-479a-aaf9-4a633d36f717" +version = "0.1.11" + [[deps.Markdown]] -deps = ["Base64"] +deps = ["Base64", "JuliaSyntaxHighlighting", "StyledStrings"] uuid = "d6f4376e-aef5-505a-96c1-9c027394607a" +version = "1.11.0" + +[[deps.Missings]] +deps = ["DataAPI"] +git-tree-sha1 = "ec4f7fbeab05d7747bdf98eb74d130a2a2ed298d" +uuid = "e1d29d7a-bbdc-5cf2-9ac0-f12de2c33e28" +version = "1.2.0" [[deps.Mmap]] uuid = "a63ad114-7e13-5084-954f-fe012c677804" +version = "1.11.0" + +[[deps.NaNMath]] +deps = ["OpenLibm_jll"] +git-tree-sha1 = "dbd2e8cd2c1c27f0b584f6661b4309609c5a685e" +uuid = "77ba4419-2d1f-58cd-9bb1-8ffee604a2e3" +version = "1.1.4" [[deps.OpenBLAS_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Libdl"] uuid = "4536629a-c528-5b80-bd46-f80d51c5b363" -version = "0.3.21+4" +version = "0.3.29+0" + +[[deps.OpenLibm_jll]] +deps = ["Artifacts", "Libdl"] +uuid = "05823500-19ac-5b8b-9628-191a04bc5112" +version = "0.8.7+0" [[deps.OrderedCollections]] git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5" @@ -160,14 +274,33 @@ version = "1.4.3" [[deps.Printf]] deps = ["Unicode"] uuid = "de0858da-6303-5e67-8744-51eddeeeb8d7" +version = "1.11.0" + +[[deps.PtrArrays]] +git-tree-sha1 = "4fbbafbc6251b883f4d2705356f3641f3652a7fe" +uuid = "43287f4e-b6f4-7ad1-bb20-aadabca52c3d" +version = "1.4.0" [[deps.REPL]] -deps = ["InteractiveUtils", "Markdown", "Sockets", "Unicode"] +deps = ["InteractiveUtils", "JuliaSyntaxHighlighting", "Markdown", "Sockets", "StyledStrings", "Unicode"] uuid = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" +version = "1.11.0" [[deps.Random]] -deps = ["SHA", "Serialization"] +deps = ["SHA"] uuid = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" +version = "1.11.0" + +[[deps.Reexport]] +git-tree-sha1 = "45e428421666073eab6f2da5c9d310d99bb12f9b" +uuid = "189a3867-3050-52da-a836-e630ba90ab69" +version = "1.2.2" + +[[deps.Requires]] +deps = ["UUIDs"] +git-tree-sha1 = "62389eeff14780bfe55195b7204c0d8738436d64" +uuid = "ae029012-a4dd-5104-9daa-d747884805df" +version = "1.3.1" [[deps.SHA]] uuid = "ea8e919c-243c-51af-8825-aaa63cd721ce" @@ -175,9 +308,63 @@ version = "0.7.0" [[deps.Serialization]] uuid = "9e88b42a-f829-5b0c-bbe9-9e923198166b" +version = "1.11.0" [[deps.Sockets]] uuid = "6462fe0b-24de-5631-8697-dd941f90decc" +version = "1.11.0" + +[[deps.SortingAlgorithms]] +deps = ["DataStructures"] +git-tree-sha1 = "64d974c2e6fdf07f8155b5b2ca2ffa9069b608d9" +uuid = "a2af1166-a08f-5f64-846c-94a0d3cef48c" +version = "1.2.2" + +[[deps.SparseArrays]] +deps = ["Libdl", "LinearAlgebra", "Random", "Serialization", "SuiteSparse_jll"] +uuid = "2f01184e-e22b-5df5-ae63-d93ebab69eaf" +version = "1.12.0" + +[[deps.StaticArrays]] +deps = ["LinearAlgebra", "PrecompileTools", "Random", "StaticArraysCore"] +git-tree-sha1 = "246a8bb2e6667f832eea063c3a56aef96429a3db" +uuid = "90137ffa-7385-5640-81b9-e52037218182" +version = "1.9.18" + + [deps.StaticArrays.extensions] + StaticArraysChainRulesCoreExt = "ChainRulesCore" + StaticArraysStatisticsExt = "Statistics" + + [deps.StaticArrays.weakdeps] + ChainRulesCore = "d360d2e6-b24c-11e9-a2a3-2a2ae2dbcce4" + Statistics = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" + +[[deps.StaticArraysCore]] +git-tree-sha1 = "6ab403037779dae8c514bad259f32a447262455a" +uuid = "1e83bf80-4336-4d27-bf5d-d5a4f845583c" +version = "1.4.4" + +[[deps.Statistics]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "ae3bb1eb3bba077cd276bc5cfc337cc65c3075c0" +uuid = "10745b16-79ce-11e8-11f9-7d13ad32a3b2" +version = "1.11.1" +weakdeps = ["SparseArrays"] + + [deps.Statistics.extensions] + SparseArraysExt = ["SparseArrays"] + +[[deps.StatsAPI]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "178ed29fd5b2a2cfc3bd31c13375ae925623ff36" +uuid = "82ae8749-77ed-4fe6-ae5f-f523153014b0" +version = "1.8.0" + +[[deps.StatsBase]] +deps = ["AliasTables", "DataAPI", "DataStructures", "IrrationalConstants", "LinearAlgebra", "LogExpFunctions", "Missings", "Printf", "Random", "SortingAlgorithms", "SparseArrays", "Statistics", "StatsAPI"] +git-tree-sha1 = "c6f18e5a52a176a383f6f6c635e0f81feed1d6d4" +uuid = "2913bbd2-ae8a-5f71-8c99-4fb6c76f3a91" +version = "0.34.11" [[deps.StructTypes]] deps = ["Dates", "UUIDs"] @@ -185,6 +372,15 @@ git-tree-sha1 = "ca4bccb03acf9faaf4137a9abc1881ed1841aa70" uuid = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" version = "1.10.0" +[[deps.StyledStrings]] +uuid = "f489334b-da3d-4c2e-b8f0-e476e12c162b" +version = "1.11.0" + +[[deps.SuiteSparse_jll]] +deps = ["Artifacts", "Libdl", "libblastrampoline_jll"] +uuid = "bea87d4a-7f5b-5778-9afe-8cc45184846c" +version = "7.8.3+2" + [[deps.TOML]] deps = ["Dates"] uuid = "fa267f1f-6049-4f14-aa54-33bafae1ed76" @@ -202,9 +398,22 @@ git-tree-sha1 = "cb76cf677714c095e535e3501ac7954732aeea2d" uuid = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" version = "1.11.1" +[[deps.TensorCore]] +deps = ["LinearAlgebra"] +git-tree-sha1 = "1feb45f88d133a655e001435632f019a9a1bcdb6" +uuid = "62fd8b95-f654-4bbd-a8a5-9c27f68ccd50" +version = "0.1.1" + +[[deps.TerminalUserInterfaces]] +deps = ["Crayons", "Crossterm", "Dates", "InlineTest", "KiwiConstraintSolver", "Logging", "LoggingExtras", "LoggingFormats", "Markdown", "REPL", "Tables", "TextWrap", "Unicode", "UnicodePlots"] +path = "." +uuid = "408767e4-586b-40c7-9657-f9758988469a" +version = "0.8.1" + [[deps.Test]] deps = ["InteractiveUtils", "Logging", "Random", "Serialization"] uuid = "8dfed614-e22c-5e08-85e1-65c5234f0b40" +version = "1.11.0" [[deps.TextWrap]] git-tree-sha1 = "9250ef9b01b66667380cf3275b3f7488d0e25faf" @@ -214,14 +423,37 @@ version = "1.0.1" [[deps.UUIDs]] deps = ["Random", "SHA"] uuid = "cf7118a7-6976-5b1a-9a39-7adc72f591a4" +version = "1.11.0" [[deps.Unicode]] uuid = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +version = "1.11.0" + +[[deps.UnicodePlots]] +deps = ["ColorSchemes", "ColorTypes", "Contour", "Crayons", "Dates", "LinearAlgebra", "MarchingCubes", "NaNMath", "PrecompileTools", "Printf", "SparseArrays", "StaticArrays", "StatsBase"] +git-tree-sha1 = "32c6b62c4a45c8be876c4916cffabf9b89c05ddd" +uuid = "b8865327-cd53-5732-bb35-84acbb429228" +version = "3.8.3" + + [deps.UnicodePlots.extensions] + FreeTypeExt = ["FileIO", "FreeType"] + ImageInTerminalExt = "ImageInTerminal" + IntervalSetsExt = "IntervalSets" + TermExt = "Term" + UnitfulExt = "Unitful" + + [deps.UnicodePlots.weakdeps] + FileIO = "5789e2e9-d7fb-5bc7-8068-2c6fae9b9549" + FreeType = "b38be410-82b0-50bf-ab77-7b57e271db43" + ImageInTerminal = "d8c32880-2388-543b-8c61-d9f865259254" + IntervalSets = "8197267c-284f-5f27-9208-e0e47529a953" + Term = "22787eb5-b846-44ae-b979-8e399b8463ab" + Unitful = "1986cc42-f94f-5a68-af5c-568840ba703d" [[deps.libblastrampoline_jll]] deps = ["Artifacts", "Libdl"] uuid = "8e850b90-86db-534c-a0d3-1478176c7d93" -version = "5.8.0+0" +version = "5.15.0+0" [[deps.libcrossterm_jll]] deps = ["Artifacts", "JLLWrappers", "Libdl"] diff --git a/Project.toml b/Project.toml index 0037944..3837929 100644 --- a/Project.toml +++ b/Project.toml @@ -17,6 +17,7 @@ REPL = "3fa0cd96-eef1-5676-8a61-b3b8758bbffb" Tables = "bd369af6-aec1-5ad0-b16a-f7cc5008161c" TextWrap = "b718987f-49a8-5099-9789-dcd902bef87d" Unicode = "4ec0a83e-493e-50e2-b9ac-8f72acf5a8f5" +UnicodePlots = "b8865327-cd53-5732-bb35-84acbb429228" [compat] Crayons = "4" @@ -28,7 +29,8 @@ LoggingFormats = "1" ReTest = "0.3" Tables = "1" TextWrap = "1" -julia = "1" +UnicodePlots = "3" +julia = "1.6" [extras] ReTest = "e0db7c4e-2690-44b9-bad6-7687da720f89" diff --git a/docs/src/usage.md b/docs/src/usage.md index fd2e8e4..7c47af9 100644 --- a/docs/src/usage.md +++ b/docs/src/usage.md @@ -54,3 +54,49 @@ main(100) ``` ![](https://user-images.githubusercontent.com/1813121/273422166-fe14909f-61f3-4595-9006-1c32f64b5869.gif) + +## UnicodePlots + +`UnicodePlot` wraps any UnicodePlots plot constructor and renders it as a TUI widget. Keep the +plot object on the model when you want mouse wheel zoom state to persist between frames, and enable +mouse capture when starting the app. + +```julia +using TerminalUserInterfaces +const TUI = TerminalUserInterfaces +const UP = TUI.UnicodePlots + +@kwdef mutable struct Model <: TUI.Model + quit = false + plot::TUI.UnicodePlot +end + +function Model() + xs = range(0, 2pi; length = 180) + ys = sin.(xs) + plot = TUI.UnicodePlot( + UP.lineplot, + collect(xs), + ys; + block = TUI.Block(; title = "UnicodePlots"), + width = 72, + height = 20, + xlabel = "x", + ylabel = "sin(x)", + compact = true, + ) + Model(false, plot) +end + +TUI.view(m::Model) = m.plot + +function TUI.update!(m::Model, evt::TUI.KeyEvent) + if TUI.keypress(evt) == "q" + m.quit = true + elseif TUI.keypress(evt) == "r" + TUI.reset_zoom!(m.plot) + end +end + +TUI.app(Model(); mouse = true) +``` diff --git a/examples/README.md b/examples/README.md index a87c706..d3919e7 100644 --- a/examples/README.md +++ b/examples/README.md @@ -23,3 +23,8 @@ Type "q" ### progressbar.jl ![](https://user-images.githubusercontent.com/1813121/273422166-fe14909f-61f3-4595-9006-1c32f64b5869.gif) + +### unicodeplot.jl + +Run with `julia --project=. examples/unicodeplot.jl`. Scroll over the plot to zoom, press `r` +to reset zoom, and press `q` to quit. diff --git a/examples/unicodeplot.jl b/examples/unicodeplot.jl new file mode 100644 index 0000000..662421a --- /dev/null +++ b/examples/unicodeplot.jl @@ -0,0 +1,43 @@ +using TerminalUserInterfaces +const TUI = TerminalUserInterfaces +const UP = TUI.UnicodePlots + +@kwdef mutable struct Model <: TUI.Model + quit = false + plot::TUI.UnicodePlot +end + +function Model() + xs = range(0, 2pi; length = 180) + ys = sin.(xs) .+ 0.25 .* cos.(3 .* xs) + plot = TUI.UnicodePlot( + UP.lineplot, + collect(xs), + ys; + block = TUI.Block(; title = "UnicodePlots"), + width = 72, + height = 20, + xlabel = "x", + ylabel = "value", + compact = true, + ) + return Model(false, plot) +end + +function TUI.view(m::Model) + return m.plot +end + +function TUI.update!(m::Model, evt::TUI.KeyEvent) + if TUI.keypress(evt) == "q" + m.quit = true + elseif TUI.keypress(evt) == "r" + TUI.reset_zoom!(m.plot) + end +end + +function main() + TUI.app(Model(); mouse = true) +end + +main() diff --git a/src/TerminalUserInterfaces.jl b/src/TerminalUserInterfaces.jl index fb71fb4..3143491 100644 --- a/src/TerminalUserInterfaces.jl +++ b/src/TerminalUserInterfaces.jl @@ -8,6 +8,7 @@ using Unicode using InlineTest using KiwiConstraintSolver using Tables +import UnicodePlots const TUI = TerminalUserInterfaces @@ -28,6 +29,7 @@ include("widgets/grid.jl") include("widgets/list.jl") include("widgets/markdown.jl") include("widgets/paragraph.jl") +include("widgets/plot.jl") include("widgets/progressbar.jl") include("widgets/scrollbar.jl") include("widgets/table.jl") diff --git a/src/app.jl b/src/app.jl index da18bef..902a7de 100644 --- a/src/app.jl +++ b/src/app.jl @@ -18,6 +18,15 @@ update!(::Model) = @info "No update" function update!(m::Model, evt::ResizeEvent) update!(terminal(m), evt) end + +function update!(m::Model, evt::MouseEvent) + handled = false + for w in views(m) + handled |= handle_event!(w, evt) + end + handled || update!(m) +end + views(m::Model) = [view(m)] view(::Model) = @debug "No view found for model" should_quit(m::Model) = m.quit @@ -27,8 +36,8 @@ render(m::Model, r::Rect, buf::Buffer) = render(w, r, buf) end -function app(m; wait = 1 / 30) - tui() do +function app(m; wait = 1 / 30, tui_kwargs...) + tui(; tui_kwargs...) do @debug "Creating terminal" t = Terminal(; wait) init!(m, t) diff --git a/src/event.jl b/src/event.jl index c84fa2e..6817550 100644 --- a/src/event.jl +++ b/src/event.jl @@ -9,3 +9,20 @@ keymodifier(evt::KeyEvent) = evt.data.modifiers keypress(evt::KeyEvent) = evt.data.kind == "Press" ? keycode(evt) : "" keypress(_) = "" + +mousekind(evt::MouseEvent) = evt.data.kind +mousekind(_) = "" + +# Crossterm reports zero-based mouse positions. The TUI layout and buffer APIs are one-based. +mousecolumn(evt::MouseEvent) = evt.data.column + 1 +mouserow(evt::MouseEvent) = evt.data.row + 1 +mouseposition(evt::MouseEvent) = (mousecolumn(evt), mouserow(evt)) +mouseposition(_) = (0, 0) + +function mousewheel(evt::MouseEvent) + kind = lowercase(mousekind(evt)) + occursin("scrollup", kind) && return 1 + occursin("scrolldown", kind) && return -1 + return 0 +end +mousewheel(_) = 0 diff --git a/src/widgets/layout.jl b/src/widgets/layout.jl index a4a144c..d523e7d 100644 --- a/src/widgets/layout.jl +++ b/src/widgets/layout.jl @@ -2,12 +2,14 @@ widgets::Vector constraints::Vector{<:Constraint} orientation::Symbol = :vertical + last_area::Base.RefValue{Union{Nothing,Rect}} = Ref{Union{Nothing,Rect}}(nothing) end function render(layout::Layout, area::Rect, buf::Buffer) if length(layout.widgets) != length(layout.constraints) throw(ArgumentError("Number of widgets must match the number of constraints")) end + layout.last_area[] = area orientation = layout.orientation rects = if orientation == :vertical @@ -20,3 +22,26 @@ function render(layout::Layout, area::Rect, buf::Buffer) render(widget, rect, buf) end end + +function handle_event!(layout::Layout, evt) + isnothing(layout.last_area[]) && return false + handle_event!(layout, evt, layout.last_area[]) +end + +function handle_event!(layout::Layout, evt, area::Rect) + if length(layout.widgets) != length(layout.constraints) + throw(ArgumentError("Number of widgets must match the number of constraints")) + end + + rects = if layout.orientation == :vertical + split(Vertical(; constraints = layout.constraints), area) + else + split(Horizontal(; constraints = layout.constraints), area) + end + + handled = false + for (widget, rect) in zip(layout.widgets, rects) + handled |= handle_event!(widget, evt, rect) + end + return handled +end diff --git a/src/widgets/plot.jl b/src/widgets/plot.jl new file mode 100644 index 0000000..302de37 --- /dev/null +++ b/src/widgets/plot.jl @@ -0,0 +1,347 @@ +const ANSI_STYLE_SEQUENCE = r"\e\[[0-9;]*m" + +mutable struct UnicodePlot + block::Block + plotter::Function + args::Tuple + kwargs::NamedTuple + width::Union{Nothing,Int} + height::Union{Nothing,Int} + xlim::Union{Nothing,Tuple{Float64,Float64}} + ylim::Union{Nothing,Tuple{Float64,Float64}} + base_xlim::Union{Nothing,Tuple{Float64,Float64}} + base_ylim::Union{Nothing,Tuple{Float64,Float64}} + zoomable::Bool + zoom_step::Float64 + max_zoom::Float64 + zoom_axes::Tuple{Vararg{Symbol}} + last_area::Union{Nothing,Rect} +end + +function UnicodePlot( + plotter::Function, + args...; + block::Block = Block(; border = BorderNone), + width = nothing, + height = nothing, + xlim = nothing, + ylim = nothing, + zoomable::Bool = true, + zoom_step::Real = 1.25, + max_zoom::Real = 64, + zoom_axes = (:x, :y), + kwargs..., +) + zoom_step = Float64(zoom_step) + max_zoom = Float64(max_zoom) + zoom_step > 1 || throw(ArgumentError("zoom_step must be greater than 1")) + max_zoom >= 1 || throw(ArgumentError("max_zoom must be greater than or equal to 1")) + + xlim = _plot_limit(xlim, "xlim") + ylim = _plot_limit(ylim, "ylim") + + return UnicodePlot( + block, + plotter, + args, + NamedTuple(kwargs), + _plot_dimension(width, "width"), + _plot_dimension(height, "height"), + xlim, + ylim, + xlim, + ylim, + zoomable, + zoom_step, + max_zoom, + _plot_zoom_axes(zoom_axes), + nothing, + ) +end + +function _plot_dimension(value, name) + isnothing(value) && return nothing + value = Int(value) + value >= 1 || throw(ArgumentError("$name must be greater than or equal to 1")) + return value +end + +_plot_limit(::Nothing, _) = nothing + +function _plot_limit(limit, name) + length(limit) == 2 || throw(ArgumentError("$name must contain exactly two values")) + lo, hi = Float64(limit[1]), Float64(limit[2]) + isfinite(lo) && isfinite(hi) && lo < hi || + throw(ArgumentError("$name must be a finite increasing pair")) + return (lo, hi) +end + +_plot_zoom_axes(axis::Symbol) = (axis,) +_plot_zoom_axes(axes) = Tuple(Symbol(axis) for axis in axes) + +function _plot_target_size(plot::UnicodePlot, area::Rect) + target_width = plot.width === nothing ? width(area) : min(width(area), plot.width) + target_height = plot.height === nothing ? height(area) : min(height(area), plot.height) + return max(0, target_width), max(0, target_height) +end + +function _build_unicode_plot(plot::UnicodePlot, canvas_width::Int, canvas_height::Int) + kwargs = merge(plot.kwargs, (; width = canvas_width, height = canvas_height)) + plot.xlim !== nothing && (kwargs = merge(kwargs, (; xlim = plot.xlim))) + plot.ylim !== nothing && (kwargs = merge(kwargs, (; ylim = plot.ylim))) + return plot.plotter(plot.args...; kwargs...) +end + +function _unicode_plot_text_lines(plot::UnicodePlots.Plot) + text = sprint(show, MIME("text/plain"), plot) + text = replace(chomp(text), ANSI_STYLE_SEQUENCE => "") + isempty(text) && return String[] + return Base.split(text, "\n"; keepempty = true) +end + +function _unicode_plot_text_lines(plot::UnicodePlot, target_width::Int, target_height::Int) + canvas_width = max(1, target_width) + canvas_height = max(1, target_height) + rendered_plot = _build_unicode_plot(plot, canvas_width, canvas_height) + lines = _unicode_plot_text_lines(rendered_plot) + + for _ in 1:(target_width + target_height + 2) + overflow_width = _text_width(lines) - target_width + overflow_height = length(lines) - target_height + overflow_width <= 0 && overflow_height <= 0 && break + + next_canvas_width = overflow_width > 0 ? max(1, canvas_width - overflow_width) : canvas_width + next_canvas_height = overflow_height > 0 ? max(1, canvas_height - overflow_height) : canvas_height + next_canvas_width == canvas_width && next_canvas_height == canvas_height && break + + canvas_width = next_canvas_width + canvas_height = next_canvas_height + rendered_plot = _build_unicode_plot(plot, canvas_width, canvas_height) + lines = _unicode_plot_text_lines(rendered_plot) + end + + _remember_plot_limits!(plot, rendered_plot) + return _clip_lines(lines, target_width, target_height) +end + +function _remember_plot_limits!(plot::UnicodePlot, rendered_plot) + hasproperty(rendered_plot, :graphics) || return plot + graphics = getproperty(rendered_plot, :graphics) + + origin_x = _float_property(graphics, :origin_x) + plot_width = _float_property(graphics, :width) + if plot.base_xlim === nothing && origin_x !== nothing && plot_width !== nothing && plot_width > 0 + plot.base_xlim = (origin_x, origin_x + plot_width) + end + + origin_y = _float_property(graphics, :origin_y) + plot_height = _float_property(graphics, :height) + if plot.base_ylim === nothing && origin_y !== nothing && plot_height !== nothing && plot_height > 0 + plot.base_ylim = (origin_y, origin_y + plot_height) + end + + return plot +end + +function _float_property(value, name::Symbol) + hasproperty(value, name) || return nothing + number = getproperty(value, name) + number isa Real || return nothing + number = Float64(number) + isfinite(number) || return nothing + return number +end + +function _text_width(lines::Vector{<:AbstractString}) + isempty(lines) && return 0 + return maximum(Unicode.textwidth(line) for line in lines) +end + +function _clip_lines(lines::Vector{<:AbstractString}, max_width::Int, max_height::Int) + (max_width <= 0 || max_height <= 0) && return String[] + line_count = min(length(lines), max_height) + return [_clip_line(lines[i], max_width) for i in 1:line_count] +end + +function _clip_line(line::AbstractString, max_width::Int) + max_width <= 0 && return "" + used_width = 0 + io = IOBuffer() + for grapheme in graphemes(line) + grapheme_width = Unicode.textwidth(String(grapheme)) + used_width + grapheme_width > max_width && break + print(io, grapheme) + used_width += max(grapheme_width, 1) + end + return String(take!(io)) +end + +function _render_text_lines!(lines::Vector{<:AbstractString}, area::Rect, buf::Buffer) + max_width = max(0, width(area)) + max_height = max(0, height(area)) + (max_width == 0 || max_height == 0) && return + + for (dy, line) in enumerate(lines) + dy > max_height && break + row = top(area) + dy - 1 + col = left(area) + max_col = left(area) + max_width - 1 + for grapheme in graphemes(line) + col > max_col && break + isempty(grapheme) && continue + set(buf, col, row, Base.first(grapheme)) + col += max(Unicode.textwidth(String(grapheme)), 1) + end + end +end + +function render(plot::UnicodePlot, area::Rect, buf::Buffer) + render(plot.block, area, buf) + plot_area = inner(plot.block, area) + plot.last_area = plot_area + + target_width, target_height = _plot_target_size(plot, plot_area) + (target_width == 0 || target_height == 0) && return + + lines = _unicode_plot_text_lines(plot, target_width, target_height) + _render_text_lines!(lines, plot_area, buf) +end + +function render(plot::UnicodePlots.Plot, area::Rect, buf::Buffer) + lines = _clip_lines(_unicode_plot_text_lines(plot), width(area), height(area)) + _render_text_lines!(lines, area, buf) +end + +function handle_event!(plot::UnicodePlot, evt::MouseEvent, area::Rect) + plot.last_area = area + return handle_event!(plot, evt) +end + +function handle_event!(plot::UnicodePlot, evt::MouseEvent) + plot.zoomable || return false + direction = mousewheel(evt) + direction == 0 && return false + plot.last_area === nothing && return false + + col, row = mouseposition(evt) + _contains(plot.last_area, col, row) || return false + return zoom!(plot, direction > 0 ? :in : :out; position = (col, row)) +end + +function zoom!(plot::UnicodePlot, direction::Symbol = :in; position = nothing) + zoom_in = direction in (:in, :up, :zoom_in) + zoom_out = direction in (:out, :down, :zoom_out) + zoom_in || zoom_out || throw(ArgumentError("direction must be :in or :out")) + + x_fraction, y_fraction = _zoom_focus(plot.last_area, position) + changed = false + + if :x in plot.zoom_axes + next_xlim = _zoom_limit(plot.xlim, plot.base_xlim, x_fraction, zoom_in, plot.zoom_step, plot.max_zoom) + changed |= next_xlim != plot.xlim + plot.xlim = next_xlim + end + + if :y in plot.zoom_axes + next_ylim = _zoom_limit(plot.ylim, plot.base_ylim, y_fraction, zoom_in, plot.zoom_step, plot.max_zoom) + changed |= next_ylim != plot.ylim + plot.ylim = next_ylim + end + + return changed +end + +function reset_zoom!(plot::UnicodePlot) + plot.xlim = plot.base_xlim + plot.ylim = plot.base_ylim + return plot +end + +function _zoom_limit(current, base, fraction::Float64, zoom_in::Bool, zoom_step::Float64, max_zoom::Float64) + limit = current === nothing ? base : current + limit === nothing && return current + + lo, hi = limit + span = hi - lo + span > 0 || return current + + base_span = base === nothing ? span : base[2] - base[1] + base_span > 0 || return current + + next_span = zoom_in ? span / zoom_step : span * zoom_step + next_span = clamp(next_span, base_span / max_zoom, base_span) + + fraction = clamp(fraction, 0.0, 1.0) + focus = lo + fraction * span + next_lo = focus - fraction * next_span + next_hi = next_lo + next_span + + if base !== nothing + base_lo, base_hi = base + if next_lo < base_lo + next_lo = base_lo + next_hi = next_lo + next_span + end + if next_hi > base_hi + next_hi = base_hi + next_lo = next_hi - next_span + end + end + + return (next_lo, next_hi) +end + +function _zoom_focus(area::Union{Nothing,Rect}, position) + area === nothing && return 0.5, 0.5 + position === nothing && return 0.5, 0.5 + + col, row = position + x_denominator = max(width(area) - 1, 1) + y_denominator = max(height(area) - 1, 1) + x_fraction = (col - left(area)) / x_denominator + y_fraction = 1 - (row - top(area)) / y_denominator + return clamp(Float64(x_fraction), 0.0, 1.0), clamp(Float64(y_fraction), 0.0, 1.0) +end + +function _contains(area::Rect, col::Integer, row::Integer) + return col >= left(area) && + col < left(area) + width(area) && + row >= top(area) && + row < top(area) + height(area) +end + +@testset "unicode-plot-render-fits-target-size" begin + plot = UnicodePlot(UnicodePlots.lineplot, 1:5, [1, 4, 9, 16, 25]; width = 30, height = 10) + lines = _unicode_plot_text_lines(plot, 30, 10) + + @test length(lines) <= 10 + @test _text_width(lines) <= 30 + @test any(occursin("┌", line) || occursin("⠤", line) for line in lines) +end + +@testset "unicode-plot-renders-into-buffer" begin + plot = UnicodePlot(UnicodePlots.lineplot, 1:5, [1, 4, 9, 16, 25]; width = 30, height = 10) + buf = Buffer(Rect(1, 1, 30, 10)) + + render(plot, Rect(1, 1, 30, 10), buf) + + @test any(cell.content != ' ' for cell in buf.content) + @test plot.base_xlim !== nothing + @test plot.base_ylim !== nothing +end + +@testset "unicode-plot-mouse-wheel-zooms" begin + plot = UnicodePlot(UnicodePlots.lineplot, 1:5, [1, 4, 9, 16, 25]; width = 30, height = 10) + buf = Buffer(Rect(1, 1, 30, 10)) + render(plot, Rect(1, 1, 30, 10), buf) + + base_xlim = plot.base_xlim + evt = Crossterm.Event{Crossterm.MouseEvent}( + Crossterm.EventTag.MOUSE, + Crossterm.MouseEvent("ScrollUp", 14, 4, String[]), + ) + + @test handle_event!(plot, evt) + @test plot.xlim !== nothing + @test plot.xlim[2] - plot.xlim[1] < base_xlim[2] - base_xlim[1] +end diff --git a/src/widgets/widgets.jl b/src/widgets/widgets.jl index 6877879..f5be3ee 100644 --- a/src/widgets/widgets.jl +++ b/src/widgets/widgets.jl @@ -3,6 +3,12 @@ Render Trait for widget """ function render(widget, area::Rect, buffer::Buffer) end +""" +Handle an input event for a widget. +""" +handle_event!(widget, evt) = false +handle_event!(widget, evt, area::Rect) = handle_event!(widget, evt) + Base.@kwdef struct Word text::String style::Crayon = Crayon() From 34fbd1e21c4b633f2124916defbd00b1cf68ff01 Mon Sep 17 00:00:00 2001 From: Rory Linehan Date: Sat, 13 Jun 2026 14:14:29 -0500 Subject: [PATCH 2/3] Fix UnicodePlot mouse dispatch and scaled zoom. Dispatch mouse events through rebuilt immediate-mode layouts to persisted children. Use the rendered plot inner area when handling plot mouse events. Keep scaled-axis zoom limits in data space while zooming in scale space. Raise Julia compat to 1.10 to match resolved UnicodePlots support. Add regression tests for rebuilt-layout zoom and log-scale zoom --- Project.toml | 2 +- src/app.jl | 15 +++- src/widgets/layout.jl | 8 ++- src/widgets/plot.jl | 158 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 169 insertions(+), 14 deletions(-) diff --git a/Project.toml b/Project.toml index 3837929..0cea164 100644 --- a/Project.toml +++ b/Project.toml @@ -30,7 +30,7 @@ ReTest = "0.3" Tables = "1" TextWrap = "1" UnicodePlots = "3" -julia = "1.6" +julia = "1.10" [extras] ReTest = "e0db7c4e-2690-44b9-bad6-7687da720f89" diff --git a/src/app.jl b/src/app.jl index 902a7de..7454e6c 100644 --- a/src/app.jl +++ b/src/app.jl @@ -19,10 +19,23 @@ function update!(m::Model, evt::ResizeEvent) update!(terminal(m), evt) end +function _event_area(m::Model) + try + return area(terminal(m)) + catch err + err isa UndefRefError && return nothing + rethrow() + end +end + +_handle_event_with_area!(widget, evt, ::Nothing) = handle_event!(widget, evt) +_handle_event_with_area!(widget, evt, area::Rect) = handle_event!(widget, evt, area) + function update!(m::Model, evt::MouseEvent) handled = false + view_area = _event_area(m) for w in views(m) - handled |= handle_event!(w, evt) + handled |= _handle_event_with_area!(w, evt, view_area) end handled || update!(m) end diff --git a/src/widgets/layout.jl b/src/widgets/layout.jl index d523e7d..4c85d3f 100644 --- a/src/widgets/layout.jl +++ b/src/widgets/layout.jl @@ -24,7 +24,13 @@ function render(layout::Layout, area::Rect, buf::Buffer) end function handle_event!(layout::Layout, evt) - isnothing(layout.last_area[]) && return false + if isnothing(layout.last_area[]) + handled = false + for widget in layout.widgets + handled |= handle_event!(widget, evt) + end + return handled + end handle_event!(layout, evt, layout.last_area[]) end diff --git a/src/widgets/plot.jl b/src/widgets/plot.jl index 302de37..8605389 100644 --- a/src/widgets/plot.jl +++ b/src/widgets/plot.jl @@ -79,6 +79,53 @@ end _plot_zoom_axes(axis::Symbol) = (axis,) _plot_zoom_axes(axes) = Tuple(Symbol(axis) for axis in axes) +_plot_exp2(x) = 2.0^x +_plot_exp10(x) = 10.0^x + +function _plot_scale_pair(scale) + scale === :identity && return (identity, identity) + scale === :ln && return (log, exp) + scale === :log2 && return (log2, _plot_exp2) + scale === :log10 && return (log10, _plot_exp10) + scale === identity && return (identity, identity) + scale === log && return (log, exp) + scale === log2 && return (log2, _plot_exp2) + scale === log10 && return (log10, _plot_exp10) + return nothing +end + +function _plot_axis_scale(plot::UnicodePlot, axis::Symbol) + key = axis == :x ? :xscale : :yscale + return get(plot.kwargs, key, :identity) +end + +function _map_plot_limit(limit, transform) + lo, hi = limit + try + lo = Float64(transform(lo)) + hi = Float64(transform(hi)) + catch + return nothing + end + + isfinite(lo) && isfinite(hi) && lo < hi || return nothing + return (lo, hi) +end + +function _scale_plot_limit(limit, scale) + pair = _plot_scale_pair(scale) + pair === nothing && return nothing + forward, _ = pair + return _map_plot_limit(limit, forward) +end + +function _unscale_plot_limit(limit, scale) + pair = _plot_scale_pair(scale) + pair === nothing && return nothing + _, inverse = pair + return _map_plot_limit(limit, inverse) +end + function _plot_target_size(plot::UnicodePlot, area::Rect) target_width = plot.width === nothing ? width(area) : min(width(area), plot.width) target_height = plot.height === nothing ? height(area) : min(height(area), plot.height) @@ -130,14 +177,28 @@ function _remember_plot_limits!(plot::UnicodePlot, rendered_plot) origin_x = _float_property(graphics, :origin_x) plot_width = _float_property(graphics, :width) - if plot.base_xlim === nothing && origin_x !== nothing && plot_width !== nothing && plot_width > 0 - plot.base_xlim = (origin_x, origin_x + plot_width) - end + _remember_axis_limit!(plot, :x, origin_x, plot_width) origin_y = _float_property(graphics, :origin_y) plot_height = _float_property(graphics, :height) - if plot.base_ylim === nothing && origin_y !== nothing && plot_height !== nothing && plot_height > 0 - plot.base_ylim = (origin_y, origin_y + plot_height) + _remember_axis_limit!(plot, :y, origin_y, plot_height) + + return plot +end + +function _remember_axis_limit!(plot::UnicodePlot, axis::Symbol, origin, size) + axis == :x && plot.base_xlim !== nothing && return plot + axis == :y && plot.base_ylim !== nothing && return plot + origin !== nothing && size !== nothing && size > 0 || return plot + + scale = _plot_axis_scale(plot, axis) + limit = _unscale_plot_limit((origin, origin + size), scale) + limit === nothing && return plot + + if axis == :x + plot.base_xlim = limit + else + plot.base_ylim = limit end return plot @@ -213,7 +274,7 @@ function render(plot::UnicodePlots.Plot, area::Rect, buf::Buffer) end function handle_event!(plot::UnicodePlot, evt::MouseEvent, area::Rect) - plot.last_area = area + plot.last_area = inner(plot.block, area) return handle_event!(plot, evt) end @@ -237,13 +298,29 @@ function zoom!(plot::UnicodePlot, direction::Symbol = :in; position = nothing) changed = false if :x in plot.zoom_axes - next_xlim = _zoom_limit(plot.xlim, plot.base_xlim, x_fraction, zoom_in, plot.zoom_step, plot.max_zoom) + next_xlim = _zoom_limit( + plot.xlim, + plot.base_xlim, + x_fraction, + zoom_in, + plot.zoom_step, + plot.max_zoom, + _plot_axis_scale(plot, :x), + ) changed |= next_xlim != plot.xlim plot.xlim = next_xlim end if :y in plot.zoom_axes - next_ylim = _zoom_limit(plot.ylim, plot.base_ylim, y_fraction, zoom_in, plot.zoom_step, plot.max_zoom) + next_ylim = _zoom_limit( + plot.ylim, + plot.base_ylim, + y_fraction, + zoom_in, + plot.zoom_step, + plot.max_zoom, + _plot_axis_scale(plot, :y), + ) changed |= next_ylim != plot.ylim plot.ylim = next_ylim end @@ -257,16 +334,30 @@ function reset_zoom!(plot::UnicodePlot) return plot end -function _zoom_limit(current, base, fraction::Float64, zoom_in::Bool, zoom_step::Float64, max_zoom::Float64) +function _zoom_limit(current, base, fraction::Float64, zoom_in::Bool, zoom_step::Float64, max_zoom::Float64, scale) limit = current === nothing ? base : current limit === nothing && return current + scaled_limit = _scale_plot_limit(limit, scale) + scaled_limit === nothing && return current + scaled_base = base === nothing ? nothing : _scale_plot_limit(base, scale) + base !== nothing && scaled_base === nothing && return current + + next_limit = _zoom_linear_limit(scaled_limit, scaled_base, fraction, zoom_in, zoom_step, max_zoom) + next_limit === scaled_limit && return current + + unscaled_limit = _unscale_plot_limit(next_limit, scale) + unscaled_limit === nothing && return current + return unscaled_limit +end + +function _zoom_linear_limit(limit, base, fraction::Float64, zoom_in::Bool, zoom_step::Float64, max_zoom::Float64) lo, hi = limit span = hi - lo - span > 0 || return current + span > 0 || return limit base_span = base === nothing ? span : base[2] - base[1] - base_span > 0 || return current + base_span > 0 || return limit next_span = zoom_in ? span / zoom_step : span * zoom_step next_span = clamp(next_span, base_span / max_zoom, base_span) @@ -345,3 +436,48 @@ end @test plot.xlim !== nothing @test plot.xlim[2] - plot.xlim[1] < base_xlim[2] - base_xlim[1] end + +@testset "unicode-plot-mouse-wheel-zooms-in-rebuilt-layout" begin + plot = UnicodePlot(UnicodePlots.lineplot, 1:5, [1, 4, 9, 16, 25]; width = 30, height = 10) + buf = Buffer(Rect(1, 1, 30, 10)) + render(Layout(; widgets = [plot], constraints = [Min(1)]), Rect(1, 1, 30, 10), buf) + + base_xlim = plot.base_xlim + rebuilt_layout = Layout(; widgets = [plot], constraints = [Min(1)]) + evt = Crossterm.Event{Crossterm.MouseEvent}( + Crossterm.EventTag.MOUSE, + Crossterm.MouseEvent("ScrollUp", 14, 4, String[]), + ) + + @test handle_event!(rebuilt_layout, evt) + @test plot.xlim !== nothing + @test plot.xlim[2] - plot.xlim[1] < base_xlim[2] - base_xlim[1] +end + +@testset "unicode-plot-log-scale-mouse-wheel-zooms-in-data-space" begin + xs = 1.0:100.0 + plot = UnicodePlot( + UnicodePlots.lineplot, + collect(xs), + collect(xs); + width = 40, + height = 12, + xscale = :log10, + yscale = :log10, + ) + buf = Buffer(Rect(1, 1, 40, 12)) + render(plot, Rect(1, 1, 40, 12), buf) + + @test plot.base_xlim[1] ≈ 1.0 + @test plot.base_xlim[2] ≈ 100.0 + + evt = Crossterm.Event{Crossterm.MouseEvent}( + Crossterm.EventTag.MOUSE, + Crossterm.MouseEvent("ScrollUp", 20, 5, String[]), + ) + + @test handle_event!(plot, evt) + @test plot.xlim[1] > 1.0 + @test plot.xlim[2] < 100.0 + @test plot.xlim[2] > 10.0 +end From 1dfa060c8d18d729a45e32aafb1eb61cf181ae40 Mon Sep 17 00:00:00 2001 From: Rory Linehan Date: Mon, 15 Jun 2026 17:06:23 -0500 Subject: [PATCH 3/3] update actions/cache version --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7555cb..f3a0c9f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - uses: actions/cache@v1 + - uses: actions/cache@v4 env: cache-name: cache-artifacts with: