Move iris's text onto parley, with a glyph atlas

Two changes that only make sense together, because the atlas is what the
new layout feeds.

Parley replaces cosmic-text for layout and shaping, and its editing model
replaces the hand-written one. That is the larger win in edit.rs: parley
addresses text by byte offset into one string rather than by (line, index),
so `select_content`, `delete_between`, `insert_inner` and `newline` become
ordinary string operations, and `iter_layout_lines`, `index_x` and
`cursor_pos` -- which walked runs by hand to place the caret and the
selection boxes -- are deleted in favour of `Selection::geometry` and
`Cursor::geometry`. Those are bidi- and wrap-correct, which the hand-written
versions were not. The file loses about 130 lines and gains Home/End.

The atlas is what the TODO's "text resizing (per frame) is really slow" was
about. Every string used to be rasterised into its own RgbaImage and
uploaded as a whole texture whenever anything changed -- so a window resize
re-rasterised and re-uploaded every visible string. Now a glyph is
rasterised once per font, size and subpixel phase and shared by every string
containing it, and a resize re-emits quads without touching the GPU's copy.
The tabs example says so directly: its `views` counter, the number of
texture views bound, goes from 6 to 1.

Supporting pieces: a GLYPH primitive that samples a sub-rectangle and tints
it, since the existing texture primitive samples a whole texture; a Patch
texture update, because re-uploading a 4 MB page per glyph is what an atlas
exists to avoid; and GpuTextures now keeps its Textures, as a view cannot be
written through.

Two bugs found on the way. `primitives!`'s @count rule recursed with commas
while matching space-separated tokens, so it only terminated for exactly two
primitives -- adding a third hit the recursion limit. And Color had no
Default, which parley's Brush requires.

Drops cosmic-text and unicode-segmentation, and with them two nightly
feature gates that nothing uses any more: portable_simd (the old glyph
compositing) and gen_blocks (the deleted line iterator). Eleven gates left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Opus 5 committed 2026-09-04 19:26:33 -04:00
1 parent 9b331a5e93
commit 68a7f41ed0
19 files changed
+1417 -791

No files matched your search

+423 -159
View File
@@ -118,7 +118,7 @@ dependencies = [
"clipboard-win", "clipboard-win",
"image", "image",
"log", "log",
"objc2 0.6.3", "objc2 0.6.4",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@@ -138,7 +138,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -318,7 +318,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -510,39 +510,6 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "core_maths"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30"
dependencies = [
"libm",
]
[[package]]
name = "cosmic-text"
version = "0.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4cadaea21e24c49c0c82116f2b465ae6a49d63c90e428b0f8d9ae1f638ac91f"
dependencies = [
"bitflags 2.10.0",
"fontdb",
"harfrust",
"linebender_resource_handle",
"log",
"rangemap",
"rustc-hash",
"self_cell",
"skrifa 0.39.0",
"smol_str",
"swash",
"sys-locale",
"unicode-bidi",
"unicode-linebreak",
"unicode-script",
"unicode-segmentation",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.0" version = "1.5.0"
@@ -602,7 +569,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2 0.6.3", "objc2 0.6.4",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
] ]
[[package]] [[package]]
@@ -658,7 +636,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -715,7 +693,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -771,26 +749,34 @@ dependencies = [
] ]
[[package]] [[package]]
name = "fontconfig-parser" name = "font-types"
version = "0.5.8" version = "0.12.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23"
dependencies = [ dependencies = [
"roxmltree", "bytemuck",
] ]
[[package]] [[package]]
name = "fontdb" name = "fontique"
version = "0.23.0" version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" checksum = "6688bc1294fe7117d788937b6c53480169b29c566954af490830d4c09da9516a"
dependencies = [ dependencies = [
"fontconfig-parser", "hashbrown 0.17.1",
"log", "linebender_resource_handle",
"memmap2", "memmap2",
"slotmap", "objc2 0.6.4",
"tinyvec", "objc2-core-foundation",
"ttf-parser", "objc2-core-text",
"objc2-foundation 0.3.2",
"parlance",
"read-fonts 0.41.0",
"roxmltree",
"smallvec",
"windows",
"windows-core",
"yeslogic-fontconfig-sys",
] ]
[[package]] [[package]]
@@ -811,7 +797,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -941,14 +927,13 @@ dependencies = [
[[package]] [[package]]
name = "harfrust" name = "harfrust"
version = "0.4.1" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0caaee032384c10dd597af4579c67dee16650d862a9ccbe1233ff1a379abc07" checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"bytemuck", "bytemuck",
"core_maths", "read-fonts 0.41.0",
"read-fonts 0.36.0",
"smallvec", "smallvec",
] ]
@@ -972,6 +957,15 @@ dependencies = [
"foldhash 0.2.0", "foldhash 0.2.0",
] ]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash 0.2.0",
]
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.5.2" version = "0.5.2"
@@ -984,6 +978,134 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df"
[[package]]
name = "icu_collections"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
dependencies = [
"displaydoc",
"potential_utf",
"utf8_iter",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "icu_locale_core"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
dependencies = [
"displaydoc",
"litemap",
"serde",
"tinystr",
"writeable",
"zerovec",
]
[[package]]
name = "icu_locale_fallback"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9"
dependencies = [
"icu_locale_core",
"icu_locale_fallback_data",
"icu_provider",
"potential_utf",
"tinystr",
"zerovec",
]
[[package]]
name = "icu_locale_fallback_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8"
[[package]]
name = "icu_normalizer"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
dependencies = [
"icu_collections",
"icu_normalizer_data",
"icu_properties",
"icu_provider",
"smallvec",
"zerovec",
]
[[package]]
name = "icu_normalizer_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
[[package]]
name = "icu_properties"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
dependencies = [
"displaydoc",
"icu_collections",
"icu_locale_core",
"icu_properties_data",
"icu_provider",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_properties_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
[[package]]
name = "icu_provider"
version = "2.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
dependencies = [
"displaydoc",
"icu_locale_core",
"serde",
"stable_deref_trait",
"writeable",
"yoke",
"zerofrom",
"zerotrie",
"zerovec",
]
[[package]]
name = "icu_segmenter"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db"
dependencies = [
"icu_collections",
"icu_locale_fallback",
"icu_provider",
"icu_segmenter_data",
"potential_utf",
"smallvec",
"utf8_iter",
"zerovec",
]
[[package]]
name = "icu_segmenter_data"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad"
[[package]] [[package]]
name = "image" name = "image"
version = "0.25.9" version = "0.25.9"
@@ -1042,7 +1164,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -1050,13 +1172,13 @@ name = "iris"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"cosmic-text",
"image", "image",
"iris-core", "iris-core",
"iris-macro", "iris-macro",
"parley",
"pollster", "pollster",
"swash",
"tokio", "tokio",
"unicode-segmentation",
"wgpu", "wgpu",
"winit", "winit",
] ]
@@ -1066,9 +1188,10 @@ name = "iris-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"cosmic-text",
"fxhash", "fxhash",
"image", "image",
"parley",
"swash",
"wgpu", "wgpu",
] ]
@@ -1078,7 +1201,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -1216,6 +1339,12 @@ version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
[[package]] [[package]]
name = "litrs" name = "litrs"
version = "1.0.0" version = "1.0.0"
@@ -1273,9 +1402,9 @@ checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]] [[package]]
name = "memmap2" name = "memmap2"
version = "0.9.9" version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@@ -1410,7 +1539,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -1462,7 +1591,7 @@ dependencies = [
"proc-macro-crate", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -1492,9 +1621,9 @@ dependencies = [
[[package]] [[package]]
name = "objc2" name = "objc2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
dependencies = [ dependencies = [
"objc2-encode", "objc2-encode",
] ]
@@ -1522,7 +1651,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2 0.6.3", "objc2 0.6.4",
"objc2-core-graphics", "objc2-core-graphics",
"objc2-foundation 0.3.2", "objc2-foundation 0.3.2",
] ]
@@ -1571,7 +1700,7 @@ checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"dispatch2", "dispatch2",
"objc2 0.6.3", "objc2 0.6.4",
] ]
[[package]] [[package]]
@@ -1582,7 +1711,7 @@ checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"dispatch2", "dispatch2",
"objc2 0.6.3", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-surface", "objc2-io-surface",
] ]
@@ -1611,6 +1740,16 @@ dependencies = [
"objc2-foundation 0.2.2", "objc2-foundation 0.2.2",
] ]
[[package]]
name = "objc2-core-text"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d"
dependencies = [
"bitflags 2.10.0",
"objc2-core-foundation",
]
[[package]] [[package]]
name = "objc2-encode" name = "objc2-encode"
version = "4.1.0" version = "4.1.0"
@@ -1637,7 +1776,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2 0.6.3", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -1648,7 +1787,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
dependencies = [ dependencies = [
"bitflags 2.10.0", "bitflags 2.10.0",
"objc2 0.6.3", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -1746,9 +1885,9 @@ dependencies = [
[[package]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.3" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]] [[package]]
name = "orbclient" name = "orbclient"
@@ -1811,6 +1950,39 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "parlance"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5"
[[package]]
name = "parley"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22d2ff88bd3f7d68d1d9b09c7e6209f9a8e8c05088295140a2bcf2e9b17038c5"
dependencies = [
"fontique",
"harfrust",
"hashbrown 0.17.1",
"icu_normalizer",
"icu_properties",
"icu_segmenter",
"linebender_resource_handle",
"parlance",
"parley_data",
"skrifa 0.44.0",
]
[[package]]
name = "parley_data"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1567535334d6ba2d3cde19221ba9a7bd0fabb3cbd99046ddfb10ae061cfcc889"
dependencies = [
"icu_properties",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@@ -1857,7 +2029,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -1920,6 +2092,17 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "potential_utf"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
dependencies = [
"serde_core",
"writeable",
"zerovec",
]
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@@ -1969,7 +2152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b"
dependencies = [ dependencies = [
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -2007,9 +2190,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.42" version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
@@ -2055,12 +2238,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde" checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde"
[[package]]
name = "rangemap"
version = "1.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68"
[[package]] [[package]]
name = "rav1e" name = "rav1e"
version = "0.8.1" version = "0.8.1"
@@ -2137,16 +2314,6 @@ dependencies = [
"crossbeam-utils", "crossbeam-utils",
] ]
[[package]]
name = "read-fonts"
version = "0.35.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358"
dependencies = [
"bytemuck",
"font-types",
]
[[package]] [[package]]
name = "read-fonts" name = "read-fonts"
version = "0.36.0" version = "0.36.0"
@@ -2154,8 +2321,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345" checksum = "5eaa2941a4c05443ee3a7b26ab076a553c343ad5995230cc2b1d3e993bdc6345"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"core_maths", "font-types 0.10.1",
"font-types", ]
[[package]]
name = "read-fonts"
version = "0.41.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
dependencies = [
"bytemuck",
"font-types 0.12.4",
"once_cell",
] ]
[[package]] [[package]]
@@ -2199,9 +2376,12 @@ checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
[[package]] [[package]]
name = "roxmltree" name = "roxmltree"
version = "0.20.0" version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "rustc-hash" name = "rustc-hash"
@@ -2275,12 +2455,6 @@ dependencies = [
"tiny-skia", "tiny-skia",
] ]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@@ -2308,7 +2482,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -2332,16 +2506,6 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "skrifa"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841"
dependencies = [
"bytemuck",
"read-fonts 0.35.0",
]
[[package]] [[package]]
name = "skrifa" name = "skrifa"
version = "0.39.0" version = "0.39.0"
@@ -2352,6 +2516,16 @@ dependencies = [
"read-fonts 0.36.0", "read-fonts 0.36.0",
] ]
[[package]]
name = "skrifa"
version = "0.44.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
dependencies = [
"bytemuck",
"read-fonts 0.41.0",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.11" version = "0.4.11"
@@ -2436,11 +2610,11 @@ checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]] [[package]]
name = "swash" name = "swash"
version = "0.2.6" version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47846491253e976bdd07d0f9cc24b7daf24720d11309302ccbbc6e6b6e53550a" checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e"
dependencies = [ dependencies = [
"skrifa 0.37.0", "skrifa 0.39.0",
"yazi", "yazi",
"zeno", "zeno",
] ]
@@ -2457,12 +2631,25 @@ dependencies = [
] ]
[[package]] [[package]]
name = "sys-locale" name = "syn"
version = "0.3.2" version = "3.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
dependencies = [ dependencies = [
"libc", "proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "synstructure"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.113",
] ]
[[package]] [[package]]
@@ -2500,7 +2687,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -2511,7 +2698,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -2554,20 +2741,16 @@ dependencies = [
] ]
[[package]] [[package]]
name = "tinyvec" name = "tinystr"
version = "1.10.0" version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
dependencies = [ dependencies = [
"tinyvec_macros", "displaydoc",
"serde_core",
"zerovec",
] ]
[[package]]
name = "tinyvec_macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.49.0" version = "1.49.0"
@@ -2639,15 +2822,6 @@ name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
dependencies = [
"core_maths",
]
[[package]]
name = "unicode-bidi"
version = "0.3.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
@@ -2655,18 +2829,6 @@ version = "1.0.22"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-script"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee"
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.12.0" version = "1.12.0"
@@ -2679,6 +2841,12 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]] [[package]]
name = "v_frame" name = "v_frame"
version = "0.3.9" version = "0.3.9"
@@ -2760,7 +2928,7 @@ dependencies = [
"bumpalo", "bumpalo",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -3120,7 +3288,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -3131,7 +3299,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
] ]
[[package]] [[package]]
@@ -3493,6 +3661,12 @@ dependencies = [
"wayland-protocols-wlr", "wayland-protocols-wlr",
] ]
[[package]]
name = "writeable"
version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"
@@ -3568,6 +3742,40 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5"
[[package]]
name = "yeslogic-fontconfig-sys"
version = "6.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76"
dependencies = [
"dlib",
"once_cell",
"pkg-config",
]
[[package]]
name = "yoke"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
"stable_deref_trait",
"yoke-derive",
"zerofrom",
]
[[package]]
name = "yoke-derive"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.113",
"synstructure",
]
[[package]] [[package]]
name = "zeno" name = "zeno"
version = "0.3.3" version = "0.3.3"
@@ -3591,7 +3799,63 @@ checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn", "syn 2.0.113",
]
[[package]]
name = "zerofrom"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
"zerofrom-derive",
]
[[package]]
name = "zerofrom-derive"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.113",
"synstructure",
]
[[package]]
name = "zerotrie"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
"displaydoc",
"yoke",
"zerofrom",
"zerovec",
]
[[package]]
name = "zerovec"
version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
"serde",
"yoke",
"zerofrom",
"zerovec-derive",
]
[[package]]
name = "zerovec-derive"
version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.5",
] ]
[[package]] [[package]]
+4 -4
View File
@@ -8,8 +8,8 @@ edition.workspace = true
[dependencies] [dependencies]
iris-core = { workspace = true } iris-core = { workspace = true }
iris-macro = { workspace = true } iris-macro = { workspace = true }
cosmic-text = { workspace = true } parley = { workspace = true }
unicode-segmentation = { workspace = true } swash = { workspace = true }
winit = { workspace = true } winit = { workspace = true }
arboard = { workspace = true, features = ["wayland-data-control"] } arboard = { workspace = true, features = ["wayland-data-control"] }
pollster = { workspace = true } pollster = { workspace = true }
@@ -33,8 +33,8 @@ winit = "0.30.12"
wgpu = "28.0.0" wgpu = "28.0.0"
bytemuck = "1.23.1" bytemuck = "1.23.1"
image = "0.25.6" image = "0.25.6"
cosmic-text = "0.16.0" parley = "0.11.1"
unicode-segmentation = "1.12.0" swash = "0.2.10"
fxhash = "0.2.1" fxhash = "0.2.1"
arboard = "3.6.1" arboard = "3.6.1"
iris-core = { path = "core" } iris-core = { path = "core" }
+2 -1
View File
@@ -7,5 +7,6 @@ edition.workspace = true
wgpu = { workspace = true } wgpu = { workspace = true }
bytemuck ={ workspace = true } bytemuck ={ workspace = true }
image = { workspace = true } image = { workspace = true }
cosmic-text = { workspace = true } parley = { workspace = true }
swash = { workspace = true }
fxhash = { workspace = true } fxhash = { workspace = true }
-1
View File
@@ -5,7 +5,6 @@
#![feature(unboxed_closures)] #![feature(unboxed_closures)]
#![feature(fn_traits)] #![feature(fn_traits)]
#![feature(const_destruct)] #![feature(const_destruct)]
#![feature(portable_simd)]
#![feature(associated_type_defaults)] #![feature(associated_type_defaults)]
#![feature(unsize)] #![feature(unsize)]
#![feature(coerce_unsized)] #![feature(coerce_unsized)]
+9
View File
@@ -10,6 +10,15 @@ pub struct Color<T> {
pub a: T, pub a: T,
} }
/// Required by parley's `Brush`, which every text style is generic over. Opaque
/// black rather than transparent: a brush that was never set should be visible
/// and obviously unstyled, not invisible.
impl<T: ColorNum> Default for Color<T> {
fn default() -> Self {
Self::BLACK
}
}
impl<T: ColorNum> Color<T> { impl<T: ColorNum> Color<T> {
pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN); pub const BLACK: Self = Self::rgb(T::MIN, T::MIN, T::MIN);
pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX); pub const WHITE: Self = Self::rgb(T::MAX, T::MAX, T::MAX);
+227 -141
View File
@@ -1,60 +1,68 @@
use crate::{Align, RegionAlign, TextureHandle, Textures, UiColor, util::Vec2}; use crate::{Align, GlyphAtlas, GlyphKey, PlacedGlyph, RegionAlign, Textures, UiColor, util::Vec2};
use cosmic_text::{ use parley::{
Attrs, AttrsList, Buffer, CacheKey, Color, Family, FontSystem, Metrics, Placement, SwashCache, Alignment, AlignmentOptions, FontContext, FontFamily, FontFamilyName, GenericFamily, Layout,
SwashContent, LayoutContext, LineHeight, PositionedLayoutItem, StyleProperty,
};
use swash::{
FontRef,
scale::{Render, ScaleContext, Source, StrikeWith},
zeno::{Format, Vector},
}; };
use image::{DynamicImage, GenericImageView, RgbaImage};
use std::simd::{Simd, num::SimdUint};
/// TODO: properly wrap this
pub mod text_lib {
pub use cosmic_text::*;
}
/// Everything text needs that outlives one string: the font collection, the
/// layout scratch space, the glyph rasteriser and the atlas they fill.
pub struct TextData { pub struct TextData {
pub font_system: FontSystem, pub font_cx: FontContext,
pub swash_cache: SwashCache, pub layout_cx: LayoutContext<UiColor>,
glyph_cache: Vec<(Placement, CacheKey, Color)>, scale_cx: ScaleContext,
pub atlas: GlyphAtlas,
} }
impl Default for TextData { impl Default for TextData {
fn default() -> Self { fn default() -> Self {
Self { Self {
font_system: FontSystem::new(), font_cx: FontContext::new(),
swash_cache: SwashCache::new(), layout_cx: LayoutContext::new(),
glyph_cache: Default::default(), scale_cx: ScaleContext::new(),
atlas: GlyphAtlas::default(),
} }
} }
} }
#[derive(Clone, Copy)] /// Which family to ask for. Kept as an owned name rather than parley's
/// borrowed `FontFamily<'_>` so that a widget can hold one without a lifetime.
#[derive(Clone, PartialEq)]
pub enum Family {
SansSerif,
Serif,
Monospace,
Named(String),
}
impl Family {
fn family(&self) -> FontFamily<'_> {
let name = match self {
Self::SansSerif => FontFamilyName::Generic(GenericFamily::SansSerif),
Self::Serif => FontFamilyName::Generic(GenericFamily::Serif),
Self::Monospace => FontFamilyName::Generic(GenericFamily::Monospace),
Self::Named(name) => FontFamilyName::Named(name.as_str().into()),
};
FontFamily::Single(name)
}
}
#[derive(Clone, PartialEq)]
pub struct TextAttrs { pub struct TextAttrs {
pub color: UiColor, pub color: UiColor,
pub font_size: f32, pub font_size: f32,
pub line_height: f32, pub line_height: f32,
pub family: Family<'static>, pub family: Family,
pub wrap: bool, pub wrap: bool,
/// inner alignment of text region (within where it's drawn) /// inner alignment of text region (within where it's drawn)
pub align: RegionAlign, pub align: RegionAlign,
} }
impl TextAttrs { pub const LINE_HEIGHT_MULT: f32 = 1.1;
pub fn apply(&self, font_system: &mut FontSystem, buf: &mut Buffer, width: Option<f32>) {
buf.set_metrics_and_size(
font_system,
Metrics::new(self.font_size, self.line_height),
width,
None,
);
let attrs = Attrs::new().family(self.family);
let list = AttrsList::new(&attrs);
for line in &mut buf.lines {
line.set_attrs_list(list.clone());
}
}
}
pub type TextBuffer = Buffer;
impl Default for TextAttrs { impl Default for TextAttrs {
fn default() -> Self { fn default() -> Self {
@@ -70,122 +78,200 @@ impl Default for TextAttrs {
} }
} }
pub const LINE_HEIGHT_MULT: f32 = 1.1; /// A string together with its laid-out form.
///
/// The text and the layout live in one place because parley's `Layout` borrows
/// nothing but is only meaningful against the string it was built from: keeping
/// them apart is how they get out of step.
pub struct TextBuffer {
text: String,
layout: Layout<UiColor>,
/// What the current layout was built for, so `shape` can decline to redo
/// work that would come out the same.
shaped: Option<(TextAttrs, Option<f32>)>,
}
impl TextData { impl TextBuffer {
pub fn draw( pub fn new(text: impl Into<String>) -> Self {
&mut self, Self {
buffer: &mut TextBuffer, text: text.into(),
attrs: &TextAttrs, layout: Layout::new(),
textures: &mut Textures, shaped: None,
) -> RenderedText {
// TODO: either this or the layout stuff (or both) is super slow,
// should probably do texture packing and things if possible.
// very visible if you add just a couple of wrapping texts and resize window
// should also be timed to figure out exactly what points need to be sped up
// let mut pixels = HashMap::<_, [u8; 4]>::default();
let mut min_x = 0;
let mut min_y = 0;
let mut max_x = 0;
let mut max_y = 0;
let text_color = {
let c = attrs.color;
cosmic_text::Color::rgba(c.r, c.g, c.b, c.a)
};
let mut max_width = 0.0f32;
let mut height = 0.0;
for run in buffer.layout_runs() {
for glyph in run.glyphs.iter() {
let physical_glyph = glyph.physical((0., 0.), 1.0);
let glyph_color = match glyph.color_opt {
Some(some) => some,
None => text_color,
};
if let Some(img) = self
.swash_cache
.get_image(&mut self.font_system, physical_glyph.cache_key)
{
let mut pos = img.placement;
pos.left += physical_glyph.x;
pos.top = physical_glyph.y + run.line_y as i32 - pos.top;
min_x = min_x.min(pos.left);
min_y = min_y.min(pos.top);
max_x = max_x.max(pos.left + pos.width as i32);
max_y = max_y.max(pos.top + pos.height as i32);
self.glyph_cache
.push((pos, physical_glyph.cache_key, glyph_color));
}
}
max_width = max_width.max(run.line_w);
height += run.line_height;
} }
let img_width = (max_x - min_x + 1) as u32; }
let img_height = (max_y - min_y + 1) as u32;
let mut image = RgbaImage::new(img_width, img_height);
for (pos, key, color) in self.glyph_cache.drain(..) { pub fn new_empty() -> Self {
let img = self Self::new("")
.swash_cache }
.get_image(&mut self.font_system, key)
.as_ref()
.unwrap();
let mut merge = |i, color: [u8; 4]| {
let i = i as i32;
let x = (i % pos.width as i32 + pos.left - min_x) as u32;
let y = (i / pos.width as i32 + pos.top - min_y) as u32;
let pixel = &mut image[(x, y)].0;
// TODO: no clue if proper alpha blending should be done
*pixel = Simd::from(color).saturating_add(Simd::from(*pixel)).into();
};
match img.content { pub fn text(&self) -> &str {
SwashContent::Mask => { &self.text
for (i, a) in img.data.iter().enumerate() { }
let mut color = color.as_rgba();
color[3] = ((color[3] as u32 * *a as u32) / u8::MAX as u32) as u8; pub fn layout(&self) -> &Layout<UiColor> {
merge(i, color); &self.layout
} }
}
SwashContent::SubpixelMask => todo!("subpixel mask text rendering"), pub fn is_empty(&self) -> bool {
SwashContent::Color => { self.text.is_empty()
let (colors, _) = img.data.as_chunks::<4>(); }
for (i, color) in colors.iter().enumerate() {
merge(i, *color); pub fn set_text(&mut self, text: impl Into<String>) {
} let text = text.into();
} if text != self.text {
} self.text = text;
self.shaped = None;
} }
}
let max_dim = 8192; /// Edit the string in place; invalidates the layout unconditionally, since
if image.width() > max_dim || image.height() > max_dim { /// the caller is assumed to have changed something.
let width = image.width().min(max_dim); pub fn edit(&mut self) -> &mut String {
let height = image.height().min(max_dim); self.shaped = None;
eprintln!( &mut self.text
"WARNING: image of size {:?} cropped to {:?} (texture too big)", }
image.dimensions(),
(width, height)
);
image = image.view(0, 0, width, height).to_image();
}
RenderedText { pub fn size(&self) -> Vec2 {
handle: textures.add(image), Vec2::new(self.layout.width(), self.layout.height())
top_left_offset: Vec2::new(min_x as f32, min_y as f32), }
size: Vec2::new(max_width, height),
/// Lay the text out, unless it is already laid out for these attributes and
/// this width.
pub fn shape(&mut self, data: &mut TextData, attrs: &TextAttrs, width: Option<f32>) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width)) {
return;
} }
let mut builder = data
.layout_cx
.ranged_builder(&mut data.font_cx, &self.text, 1.0, true);
builder.push_default(StyleProperty::FontFamily(attrs.family.family()));
builder.push_default(StyleProperty::FontSize(attrs.font_size));
builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(
attrs.line_height,
)));
builder.push_default(StyleProperty::Brush(attrs.color));
builder.build_into(&mut self.layout, &self.text);
self.layout.break_all_lines(width);
self.layout
.align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width));
} }
} }
#[derive(Clone)] impl TextData {
pub struct RenderedText { /// Rasterise whatever of `buffer` is not in the atlas yet, and return where
pub handle: TextureHandle, /// each glyph goes relative to the text's top-left.
pub top_left_offset: Vec2, ///
pub size: Vec2, /// Nothing is uploaded for a glyph already in the atlas, which is the point
/// of having one: a resize re-runs this and touches the GPU only if the new
/// width brought genuinely new glyphs into view.
pub fn place(&mut self, buffer: &TextBuffer, textures: &mut Textures) -> Vec<PlacedGlyph> {
let mut placed = Vec::new();
for line in buffer.layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(run) = item else {
continue;
};
let font = run.run().font();
let font_size = run.run().font_size();
let coords = run.run().normalized_coords();
let Some(font_ref) = FontRef::from_index(font.data.as_ref(), font.index as usize)
else {
continue;
};
let coords_hash = hash_coords(coords);
// `font.data.id()` rather than the pointer, so the same font
// loaded twice is still one set of entries.
let font_id = font.data.id();
for glyph in run.positioned_glyphs() {
let subpixel = ((glyph.x.fract() * 4.0).round() as i32).rem_euclid(4) as u8;
let key = GlyphKey {
font: font_id,
glyph: glyph.id,
size: (font_size * 16.0).round() as u32,
subpixel,
coords: coords_hash,
};
let entry = match self.atlas.get(&key) {
Some(entry) => entry,
None => {
let mut scaler = self
.scale_cx
.builder(font_ref)
.size(font_size)
.hint(true)
.normalized_coords(coords)
.build();
let image = Render::new(&[
Source::ColorOutline(0),
Source::ColorBitmap(StrikeWith::BestFit),
Source::Outline,
])
.format(Format::Alpha)
.offset(Vector::new(subpixel as f32 / 4.0, 0.0))
.render(&mut scaler, glyph.id as u16);
match image {
Some(image) => self.atlas.insert(key, &image, textures),
None => {
self.atlas.insert_empty(key);
None
}
}
}
};
let Some(entry) = entry else { continue };
placed.push(PlacedGlyph {
entry,
offset: Vec2::new(
glyph.x.floor() + entry.left as f32,
glyph.y.floor() - entry.top as f32,
),
});
}
}
}
placed
}
} }
pub trait HasTextures { fn hash_coords(coords: &[i16]) -> u64 {
fn add_texture(&mut self, image: DynamicImage) -> TextureHandle; // FxHash over the coordinates; they are short and change rarely.
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for c in coords {
h ^= *c as u16 as u64;
h = h.wrapping_mul(0x1000_0000_01b3);
}
h
}
/// A laid-out string, ready to draw: where each glyph goes, how big the whole
/// thing is, and what colour to tint the atlas with.
///
/// Cheap to clone and to keep, which is the point -- a widget holds one across
/// frames and re-emits its quads without going near the rasteriser.
#[derive(Clone)]
pub struct RenderedText {
pub glyphs: std::sync::Arc<Vec<PlacedGlyph>>,
pub size: Vec2,
pub color: UiColor,
}
impl TextData {
/// Lay out and place in one step, which is what a widget wants.
pub fn render(
&mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
textures: &mut Textures,
) -> RenderedText {
buffer.shape(self, attrs, width);
let glyphs = self.place(buffer, textures);
RenderedText {
glyphs: std::sync::Arc::new(glyphs),
size: buffer.size(),
color: attrs.color,
}
}
} }
+30
View File
@@ -29,14 +29,27 @@ pub struct Textures {
pub enum TextureUpdate<'a> { pub enum TextureUpdate<'a> {
Push(&'a DynamicImage), Push(&'a DynamicImage),
Set(u32, &'a DynamicImage), Set(u32, &'a DynamicImage),
/// Overwrite a rectangle of an existing texture, rather than replacing it.
/// The glyph atlas grows a glyph at a time, and re-uploading a whole atlas
/// per glyph is megabytes of copy for a few hundred bytes of change.
Patch(u32, PatchRect, &'a DynamicImage),
Free(u32), Free(u32),
PushFree, PushFree,
SetFree, SetFree,
} }
#[derive(Debug, Clone, Copy)]
pub struct PatchRect {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
}
enum Update { enum Update {
Push(u32), Push(u32),
Set(u32), Set(u32),
Patch(u32, PatchRect),
Free(u32), Free(u32),
} }
@@ -81,6 +94,19 @@ impl Textures {
} }
} }
/// The stored image for a handle, to be written into before `patch`.
pub fn image_mut(&mut self, handle: &TextureHandle) -> &mut DynamicImage {
self.images[handle.inner.view_idx as usize]
.as_mut()
.expect("texture was freed while still held")
}
/// Queue an upload of just `rect`, after writing it with `image_mut`.
pub fn patch(&mut self, handle: &TextureHandle, rect: PatchRect) {
self.updates
.push(Update::Patch(handle.inner.view_idx, rect));
}
pub fn free(&mut self) { pub fn free(&mut self) {
for idx in self.recv.try_iter() { for idx in self.recv.try_iter() {
self.images[idx as usize] = None; self.images[idx as usize] = None;
@@ -99,6 +125,10 @@ impl Textures {
.as_ref() .as_ref()
.map(|img| TextureUpdate::Set(i, img)) .map(|img| TextureUpdate::Set(i, img))
.unwrap_or(TextureUpdate::SetFree), .unwrap_or(TextureUpdate::SetFree),
Update::Patch(i, rect) => self.images[i as usize]
.as_ref()
.map(|img| TextureUpdate::Patch(i, rect, img))
.unwrap_or(TextureUpdate::SetFree),
Update::Free(i) => TextureUpdate::Free(i), Update::Free(i) => TextureUpdate::Free(i),
}) })
} }
+234
View File
@@ -0,0 +1,234 @@
//! A glyph atlas: one texture holding many rasterised glyphs, so drawing text
//! is a quad per glyph rather than a texture per string.
//!
//! What this replaces is why it exists. Text used to be rasterised into its own
//! `RgbaImage` and uploaded as a whole texture, per text widget, every time
//! anything about it changed -- so every window resize re-rasterised and
//! re-uploaded every visible string, which is what the TODO meant by "resizing
//! (per frame) is really slow". Here a glyph is rasterised once for a given
//! font, size and subpixel offset and then reused by every string that contains
//! it, and a resize re-emits quads without touching the GPU's copy at all.
use crate::{
PatchRect, TextureHandle, Textures,
util::{HashMap, Vec2},
};
use image::RgbaImage;
use swash::scale::image::{Content, Image};
/// Side of one atlas page, in pixels. 1024 is 4 MB at RGBA8 -- enough for a
/// few thousand glyphs at UI sizes, and small enough that a page nobody fills
/// is not a big waste.
const PAGE: u32 = 1024;
/// Transparent margin kept around every glyph, so that sampling one cannot
/// pick up its neighbour along a shared edge.
const PAD: u32 = 1;
/// Identifies a rasterised glyph. Anything that changes the pixels has to be in
/// here, or two different glyphs share one entry and the wrong one is drawn.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct GlyphKey {
pub font: u64,
pub glyph: u32,
/// Font size in 1/16 px, so sizes that round to the same pixels share a
/// raster instead of filling the atlas with near-duplicates.
pub size: u32,
/// Horizontal subpixel phase, in 1/4 px.
pub subpixel: u8,
/// Hash of the variation coordinates; a variable font at two weights is two
/// different sets of pixels from one glyph id.
pub coords: u64,
}
#[derive(Clone, Copy)]
pub struct GlyphEntry {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
/// Offset from the glyph's pen position to the top-left of its pixels.
pub left: i32,
pub top: i32,
pub width: u32,
pub height: u32,
pub is_color: bool,
pub view_idx: u32,
pub sampler_idx: u32,
}
struct Page {
handle: TextureHandle,
/// Shelf packing: glyphs are placed left to right along a shelf whose
/// height is the tallest glyph on it, and a new shelf starts above when the
/// row runs out. Chosen over a real packer because glyphs at one size are
/// close to the same height, which is the case shelves are good at.
x: u32,
y: u32,
shelf_height: u32,
}
#[derive(Default)]
pub struct GlyphAtlas {
pages: Vec<Page>,
/// `None` for a glyph that rasterised to nothing -- a space, say. Cached
/// too, so it is not re-rasterised on every layout.
entries: HashMap<GlyphKey, Option<GlyphEntry>>,
}
impl GlyphAtlas {
pub fn get(&self, key: &GlyphKey) -> Option<Option<GlyphEntry>> {
self.entries.get(key).copied()
}
/// Rasterised pixels in, a place in the atlas out. `None` means the glyph
/// has no pixels, which is a normal answer rather than a failure.
pub fn insert(
&mut self,
key: GlyphKey,
image: &Image,
textures: &mut Textures,
) -> Option<GlyphEntry> {
let w = image.placement.width;
let h = image.placement.height;
if w == 0 || h == 0 {
self.entries.insert(key, None);
return None;
}
if w + PAD * 2 > PAGE || h + PAD * 2 > PAGE {
// A single glyph larger than a page. Refusing is better than
// silently drawing a cropped one; the caller draws nothing.
self.entries.insert(key, None);
return None;
}
let (page_idx, x, y) = self.allocate(w, h, textures);
let page = &self.pages[page_idx];
let img = textures.image_mut(&page.handle);
let rgba = img.as_mut_rgba8().expect("atlas page is rgba8");
write_glyph(rgba, image, x, y);
let handle = page.handle.clone();
let rect = PatchRect {
x,
y,
width: w,
height: h,
};
textures.patch(&handle, rect);
let page = &self.pages[page_idx];
let scale = 1.0 / PAGE as f32;
let entry = GlyphEntry {
uv_min: [x as f32 * scale, y as f32 * scale],
uv_max: [(x + w) as f32 * scale, (y + h) as f32 * scale],
left: image.placement.left,
top: image.placement.top,
width: w,
height: h,
is_color: matches!(image.content, Content::Color),
view_idx: page.handle.primitive().view_idx,
sampler_idx: page.handle.primitive().sampler_idx,
};
self.entries.insert(key, Some(entry));
Some(entry)
}
/// A free `w`x`h` spot, opening a shelf or a page as needed.
fn allocate(&mut self, w: u32, h: u32, textures: &mut Textures) -> (usize, u32, u32) {
let need_w = w + PAD;
let need_h = h + PAD;
if let Some(i) = self.pages.iter().position(|p| fits(p, need_w, need_h)) {
let page = &mut self.pages[i];
if page.x + need_w > PAGE {
page.y += page.shelf_height;
page.x = PAD;
page.shelf_height = 0;
}
let (x, y) = (page.x, page.y);
page.x += need_w;
page.shelf_height = page.shelf_height.max(need_h);
return (i, x, y);
}
let handle = textures.add(RgbaImage::new(PAGE, PAGE));
self.pages.push(Page {
handle,
x: PAD + w + PAD,
y: PAD,
shelf_height: h + PAD,
});
(self.pages.len() - 1, PAD, PAD)
}
/// Record that a glyph has no pixels, so it is not re-rasterised.
pub fn insert_empty(&mut self, key: GlyphKey) {
self.entries.insert(key, None);
}
pub fn page_count(&self) -> usize {
self.pages.len()
}
pub fn glyph_count(&self) -> usize {
self.entries.len()
}
}
fn fits(page: &Page, need_w: u32, need_h: u32) -> bool {
// On the current shelf, or on a new one above it.
(page.x + need_w <= PAGE && page.y + need_h <= PAGE)
|| (need_w + PAD <= PAGE && page.y + page.shelf_height + need_h <= PAGE)
}
/// Copy one rasterised glyph into the page image at `(x, y)`.
///
/// A mask glyph keeps its coverage in alpha with the colour left to the shader,
/// so one raster serves text of any colour; a colour glyph carries its own.
fn write_glyph(page: &mut RgbaImage, image: &Image, x: u32, y: u32) {
let w = image.placement.width;
let h = image.placement.height;
match image.content {
Content::Mask => {
for row in 0..h {
for col in 0..w {
let a = image.data[(row * w + col) as usize];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
Content::Color => {
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let px = [
image.data[i],
image.data[i + 1],
image.data[i + 2],
image.data[i + 3],
];
page.put_pixel(x + col, y + row, image::Rgba(px));
}
}
}
Content::SubpixelMask => {
// Not asked for: `Format::Alpha` is what the renderer requests, so
// reaching here means the request changed and this needs writing.
// Drawn as a plain mask from the green channel rather than dropped,
// so the text is readable rather than absent.
for row in 0..h {
for col in 0..w {
let i = ((row * w + col) * 4) as usize;
let a = image.data[i + 1];
page.put_pixel(x + col, y + row, image::Rgba([255, 255, 255, a]));
}
}
}
}
}
/// Where a glyph goes on screen, in pixels relative to the text's origin.
#[derive(Clone, Copy)]
pub struct PlacedGlyph {
pub entry: GlyphEntry,
pub offset: Vec2,
}
+2
View File
@@ -11,11 +11,13 @@ use wgpu::{
*, *,
}; };
mod atlas;
mod data; mod data;
mod primitive; mod primitive;
mod texture; mod texture;
mod util; mod util;
pub use atlas::*;
pub use data::{Mask, MaskIdx}; pub use data::{Mask, MaskIdx};
pub use primitive::*; pub use primitive::*;
+30 -1
View File
@@ -93,7 +93,13 @@ macro_rules! primitives {
} }
)* )*
}; };
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t),+) }; // The recursion has to hand back the same shape it matches -- space
// separated, not comma separated. Written with `$($t),+` it re-entered
// with a comma as the first token and never terminated, which happened to
// work only because there were exactly two primitives: the first step left
// a single token, and a single token matches the base case whichever
// separator it was written with.
(@count $t1:tt $($t:tt)+) => { 1 + primitives!(@count $($t)+) };
(@count $t:tt) => { 1 }; (@count $t:tt) => { 1 };
} }
@@ -201,6 +207,7 @@ impl PrimitiveHandle {
primitives!( primitives!(
rects: RectPrimitive => 0, rects: RectPrimitive => 0,
textures: TexturePrimitive => 1, textures: TexturePrimitive => 1,
glyphs: GlyphPrimitive => 2,
); );
#[repr(C)] #[repr(C)]
@@ -230,6 +237,28 @@ pub struct TexturePrimitive {
pub sampler_idx: u32, pub sampler_idx: u32,
} }
/// One glyph, drawn as a sub-rectangle of the glyph atlas.
///
/// Separate from `TexturePrimitive` because that one samples a whole texture:
/// text needs many quads sharing one atlas, which is the whole point of having
/// an atlas. `color` is the text colour and is multiplied by the atlas's alpha
/// for an ordinary mask glyph; a colour glyph (emoji) carries its own colour
/// and takes the atlas texel unchanged, which is what `IS_COLOR` selects.
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct GlyphPrimitive {
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
pub view_idx: u32,
pub sampler_idx: u32,
pub color: Color<u8>,
pub flags: u32,
}
impl GlyphPrimitive {
pub const IS_COLOR: u32 = 1;
}
pub struct PrimitiveVec<T> { pub struct PrimitiveVec<T> {
vec: Vec<T>, vec: Vec<T>,
free: Vec<usize>, free: Vec<usize>,
+26
View File
@@ -1,5 +1,6 @@
const RECT: u32 = 0u; const RECT: u32 = 0u;
const TEXTURE: u32 = 1u; const TEXTURE: u32 = 1u;
const GLYPH: u32 = 2u;
@group(0) @binding(0) @group(0) @binding(0)
var<uniform> window: WindowUniform; var<uniform> window: WindowUniform;
@@ -7,6 +8,8 @@ var<uniform> window: WindowUniform;
var<storage> rects: array<Rect>; var<storage> rects: array<Rect>;
@group(1) @binding(TEXTURE) @group(1) @binding(TEXTURE)
var<storage> textures: array<TextureInfo>; var<storage> textures: array<TextureInfo>;
@group(1) @binding(GLYPH)
var<storage> glyphs: array<GlyphInfo>;
struct Rect { struct Rect {
color: u32, color: u32,
@@ -20,6 +23,15 @@ struct TextureInfo {
sampler_idx: u32, sampler_idx: u32,
} }
struct GlyphInfo {
uv_min: vec2<f32>,
uv_max: vec2<f32>,
view_idx: u32,
sampler_idx: u32,
color: u32,
flags: u32,
}
struct Mask { struct Mask {
x: UiSpan, x: UiSpan,
y: UiSpan, y: UiSpan,
@@ -125,6 +137,9 @@ fn fs_main(
case TEXTURE: { case TEXTURE: {
color = draw_texture(region, textures[i]); color = draw_texture(region, textures[i]);
} }
case GLYPH: {
color = draw_glyph(region, glyphs[i]);
}
default: { default: {
color = vec4(1.0, 0.0, 1.0, 1.0); color = vec4(1.0, 0.0, 1.0, 1.0);
} }
@@ -148,6 +163,17 @@ fn draw_texture(region: Region, info: TextureInfo) -> vec4<f32> {
return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv); return textureSample(views[info.view_idx], samplers[info.sampler_idx], region.uv);
} }
fn draw_glyph(region: Region, g: GlyphInfo) -> vec4<f32> {
let uv = mix(g.uv_min, g.uv_max, region.uv);
let texel = textureSample(views[g.view_idx], samplers[g.sampler_idx], uv);
if (g.flags & 1u) != 0u {
return texel;
}
var color = unpack4x8unorm(g.color);
color.a *= texel.a;
return color;
}
fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> { fn draw_rounded_rect(region: Region, rect: Rect) -> vec4<f32> {
var color = unpack4x8unorm(rect.color); var color = unpack4x8unorm(rect.color);
+62 -7
View File
@@ -1,11 +1,15 @@
use image::{DynamicImage, EncodableLayout}; use image::{DynamicImage, EncodableLayout, GenericImageView};
use wgpu::{util::DeviceExt, *}; use wgpu::{util::DeviceExt, *};
use crate::{TextureUpdate, Textures}; use crate::{PatchRect, TextureUpdate, Textures};
pub struct GpuTextures { pub struct GpuTextures {
device: Device, device: Device,
queue: Queue, queue: Queue,
/// Kept alongside the views because a patch writes into the texture, and a
/// view cannot be written through. Parallel to `views`; `None` where the
/// slot is the shared null view.
textures: Vec<Option<Texture>>,
views: Vec<TextureView>, views: Vec<TextureView>,
view_count: usize, view_count: usize,
samplers: Vec<Sampler>, samplers: Vec<Sampler>,
@@ -21,6 +25,13 @@ impl GpuTextures {
match update { match update {
TextureUpdate::Push(image) => self.push(image), TextureUpdate::Push(image) => self.push(image),
TextureUpdate::Set(i, image) => self.set(i, image), TextureUpdate::Set(i, image) => self.set(i, image),
TextureUpdate::Patch(i, rect, image) => {
// A patch changes texture contents, not the binding array,
// so it must not report `changed` -- rebuilding the bind
// group per glyph is the cost this exists to avoid.
self.patch(i, rect, image);
changed = false;
}
TextureUpdate::SetFree => self.view_count += 1, TextureUpdate::SetFree => self.view_count += 1,
TextureUpdate::Free(i) => self.free(i), TextureUpdate::Free(i) => self.free(i),
TextureUpdate::PushFree => self.push_free(), TextureUpdate::PushFree => self.push_free(),
@@ -30,24 +41,66 @@ impl GpuTextures {
} }
fn set(&mut self, i: u32, image: &DynamicImage) { fn set(&mut self, i: u32, image: &DynamicImage) {
self.view_count += 1; self.view_count += 1;
let view = self.create_view(image); let (texture, view) = self.create(image);
self.textures[i as usize] = Some(texture);
self.views[i as usize] = view; self.views[i as usize] = view;
} }
fn free(&mut self, i: u32) { fn free(&mut self, i: u32) {
self.view_count -= 1; self.view_count -= 1;
self.textures[i as usize] = None;
self.views[i as usize] = self.null_view.clone(); self.views[i as usize] = self.null_view.clone();
} }
fn push(&mut self, image: &DynamicImage) { fn push(&mut self, image: &DynamicImage) {
self.view_count += 1; self.view_count += 1;
let view = self.create_view(image); let (texture, view) = self.create(image);
self.textures.push(Some(texture));
self.views.push(view); self.views.push(view);
} }
fn push_free(&mut self) { fn push_free(&mut self) {
self.view_count += 1; self.view_count += 1;
self.textures.push(None);
self.views.push(self.null_view.clone()); self.views.push(self.null_view.clone());
} }
fn create_view(&self, image: &DynamicImage) -> TextureView { fn patch(&mut self, i: u32, rect: PatchRect, image: &DynamicImage) {
let Some(texture) = &self.textures[i as usize] else {
return;
};
if rect.width == 0 || rect.height == 0 {
return;
}
// Cropped rather than written straight from the atlas, because
// write_texture wants tightly packed rows and the atlas rows are as
// wide as the atlas. A glyph is small, so the copy is too.
let sub = image
.view(rect.x, rect.y, rect.width, rect.height)
.to_image();
self.queue.write_texture(
TexelCopyTextureInfo {
texture,
mip_level: 0,
origin: Origin3d {
x: rect.x,
y: rect.y,
z: 0,
},
aspect: TextureAspect::All,
},
sub.as_bytes(),
TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(rect.width * 4),
rows_per_image: Some(rect.height),
},
Extent3d {
width: rect.width,
height: rect.height,
depth_or_array_layers: 1,
},
);
}
fn create(&self, image: &DynamicImage) -> (Texture, TextureView) {
let image = image.to_rgba8(); let image = image.to_rgba8();
let (width, height) = image.dimensions(); let (width, height) = image.dimensions();
let texture = self.device.create_texture_with_data( let texture = self.device.create_texture_with_data(
@@ -63,13 +116,14 @@ impl GpuTextures {
sample_count: 1, sample_count: 1,
dimension: TextureDimension::D2, dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm, format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING, usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[], view_formats: &[],
}, },
wgt::TextureDataOrder::MipMajor, wgt::TextureDataOrder::MipMajor,
image.as_bytes(), image.as_bytes(),
); );
texture.create_view(&TextureViewDescriptor::default()) let view = texture.create_view(&TextureViewDescriptor::default());
(texture, view)
} }
pub fn new(device: &Device, queue: &Queue) -> Self { pub fn new(device: &Device, queue: &Queue) -> Self {
@@ -77,6 +131,7 @@ impl GpuTextures {
Self { Self {
device: device.clone(), device: device.clone(),
queue: queue.clone(), queue: queue.clone(),
textures: Vec::new(),
views: Vec::new(), views: Vec::new(),
samplers: vec![default_sampler(device)], samplers: vec![default_sampler(device)],
no_views: vec![null_view.clone()], no_views: vec![null_view.clone()],
+43 -5
View File
@@ -1,7 +1,7 @@
use crate::{ use crate::{
Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData, Axis, Len, RenderedText, Size, SizeCtx, StrongWidget, TextAttrs, TextBuffer, TextData,
TextureHandle, UiRegion, UiRenderState, UiRsc, Widget, WidgetId, TextureHandle, UiRegion, UiRenderState, UiRsc, UiScalar, UiVec2, Widget, WidgetId,
render::{Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst}, render::{GlyphPrimitive, Mask, MaskIdx, Primitive, PrimitiveHandle, PrimitiveInst},
util::Vec2, util::Vec2,
}; };
@@ -90,10 +90,48 @@ impl<'a> Painter<'a> {
self.primitive_at(handle.primitive(), region); self.primitive_at(handle.primitive(), region);
} }
/// returns (handle, offset from top left) pub fn render_text(
pub fn render_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText { &mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
let ui = self.rsc.ui_mut(); let ui = self.rsc.ui_mut();
ui.text.draw(buffer, attrs, &mut ui.textures) ui.text.render(buffer, attrs, width, &mut ui.textures)
}
/// Draw a laid-out string: one quad per glyph, all sampling the atlas.
///
/// `origin` is where the text's top-left goes; every glyph is placed at an
/// absolute pixel offset from it, so re-drawing after a resize is this loop
/// and nothing else.
pub fn glyphs(&mut self, text: &RenderedText, origin: UiRegion) {
let flags_for = |is_color| {
if is_color {
GlyphPrimitive::IS_COLOR
} else {
0
}
};
for glyph in text.glyphs.iter() {
let mut region = origin;
region.x.end = region.x.start;
region.y.end = region.y.start;
let mut region = region.offset(UiVec2::abs(glyph.offset));
region.x.end = region.x.start + UiScalar::abs(glyph.entry.width as f32);
region.y.end = region.y.start + UiScalar::abs(glyph.entry.height as f32);
self.primitive_at(
GlyphPrimitive {
uv_min: glyph.entry.uv_min,
uv_max: glyph.entry.uv_max,
view_idx: glyph.entry.view_idx,
sampler_idx: glyph.entry.sampler_idx,
color: text.color,
flags: flags_for(glyph.entry.is_color),
},
region,
);
}
} }
pub fn region(&self) -> UiRegion { pub fn region(&self) -> UiRegion {
+7 -2
View File
@@ -76,8 +76,13 @@ impl SizeCtx<'_> {
self.output_size self.output_size
} }
pub fn draw_text(&mut self, buffer: &mut TextBuffer, attrs: &TextAttrs) -> RenderedText { pub fn draw_text(
self.text.draw(buffer, attrs, self.textures) &mut self,
buffer: &mut TextBuffer,
attrs: &TextAttrs,
width: Option<f32>,
) -> RenderedText {
self.text.render(buffer, attrs, width, self.textures)
} }
pub fn label(&self, id: WidgetId) -> &String { pub fn label(&self, id: WidgetId) -> &String {
-1
View File
@@ -1,4 +1,3 @@
use cosmic_text::Family;
use std::{cell::RefCell, rc::Rc}; use std::{cell::RefCell, rc::Rc};
use winit::event::WindowEvent; use winit::event::WindowEvent;
-1
View File
@@ -1,6 +1,5 @@
#![feature(unboxed_closures)] #![feature(unboxed_closures)]
#![feature(fn_traits)] #![feature(fn_traits)]
#![feature(gen_blocks)]
#![feature(associated_type_defaults)] #![feature(associated_type_defaults)]
#![feature(unsize)] #![feature(unsize)]
#![feature(option_into_flat_iter)] #![feature(option_into_flat_iter)]
+5 -20
View File
@@ -1,5 +1,4 @@
use crate::prelude::*; use crate::prelude::*;
use cosmic_text::{Attrs, Family, Metrics};
use std::marker::{PhantomData, Sized}; use std::marker::{PhantomData, Sized};
pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> { pub struct TextBuilder<State, O = TextOutput, H: WidgetOption<State> = ()> {
@@ -20,7 +19,7 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
self.attrs.color = color; self.attrs.color = color;
self self
} }
pub fn family(mut self, family: Family<'static>) -> Self { pub fn family(mut self, family: Family) -> Self {
self.attrs.family = family; self.attrs.family = family;
self self
} }
@@ -82,19 +81,13 @@ impl<Rsc: UiRsc> TextBuilderOutput<Rsc> for TextOutput {
state: &mut Rsc, state: &mut Rsc,
builder: TextBuilder<Rsc, Self, H>, builder: TextBuilder<Rsc, Self, H>,
) -> Self::Output { ) -> Self::Output {
let mut buf = TextBuffer::new_empty(Metrics::new( let buf = TextBuffer::new(&builder.content);
builder.attrs.font_size,
builder.attrs.line_height,
));
let hint = builder.hint.get(state); let hint = builder.hint.get(state);
let font_system = &mut state.ui_mut().text.font_system;
buf.set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None);
let mut text = Text { let mut text = Text {
content: builder.content.into(), content: builder.content.into(),
view: TextView::new(buf, builder.attrs, hint), view: TextView::new(buf, builder.attrs, hint),
}; };
text.content.changed = false; text.content.changed = false;
builder.attrs.apply(font_system, &mut text.view.buf, None);
text text
} }
} }
@@ -110,19 +103,11 @@ impl<State: UiRsc> TextBuilderOutput<State> for TextEditOutput {
state: &mut State, state: &mut State,
builder: TextBuilder<State, Self, H>, builder: TextBuilder<State, Self, H>,
) -> Self::Output { ) -> Self::Output {
let buf = TextBuffer::new_empty(Metrics::new( let buf = TextBuffer::new(&builder.content);
builder.attrs.font_size, TextEdit::new(
builder.attrs.line_height,
));
let mut text = TextEdit::new(
TextView::new(buf, builder.attrs, builder.hint.get(state)), TextView::new(buf, builder.attrs, builder.hint.get(state)),
builder.output.mode, builder.output.mode,
); )
let font_system = &mut state.ui_mut().text.font_system;
text.buf
.set_text(font_system, &builder.content, &Attrs::new(), SHAPING, None);
builder.attrs.apply(font_system, &mut text.buf, None);
text
} }
} }
+285 -393
View File
@@ -1,17 +1,35 @@
use crate::prelude::*; use crate::prelude::*;
use cosmic_text::{Affinity, Attrs, Cursor, FontSystem, LayoutRun, Motion}; use iris_core::{TextData, UiColor};
use parley::{Affinity, Layout, Selection};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use unicode_segmentation::UnicodeSegmentation;
use winit::{ use winit::{
event::KeyEvent, event::KeyEvent,
keyboard::{Key, NamedKey}, keyboard::{Key, NamedKey},
}; };
/// Which way a cursor movement goes. Named here rather than taken from the text
/// stack so that the key handling below does not have to change when the stack
/// does; the mapping onto parley lives in one place, in `apply_motion`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Motion {
Left,
Right,
LeftWord,
RightWord,
Up,
Down,
LineStart,
LineEnd,
}
pub struct TextEdit { pub struct TextEdit {
view: TextView, view: TextView,
selection: TextSelection, /// `None` when the field is not focused -- which parley's `Selection` has no
history: Vec<(String, TextSelection)>, /// way to say, since it always denotes some position in the text. A
double_hit: Option<Cursor>, /// collapsed selection is a caret; an uncollapsed one is a span.
selection: Option<Selection>,
history: Vec<(String, Option<Selection>)>,
double_hit: Option<usize>,
pub mode: EditMode, pub mode: EditMode,
} }
@@ -25,27 +43,19 @@ impl TextEdit {
pub fn new(view: TextView, mode: EditMode) -> Self { pub fn new(view: TextView, mode: EditMode) -> Self {
Self { Self {
view, view,
selection: Default::default(), selection: None,
history: Default::default(), history: Default::default(),
double_hit: None, double_hit: None,
mode, mode,
} }
} }
pub fn select_content(&self, start: Cursor, end: Cursor) -> String {
let (start, end) = sort_cursors(start, end); pub fn selected_text(&self) -> Option<String> {
let mut iter = self.buf.lines.iter().skip(start.line); let sel = self.selection?;
let first = iter.next().unwrap(); if sel.is_collapsed() {
if start.line == end.line { return None;
first.text()[start.index..end.index].to_string()
} else {
let mut str = first.text()[start.index..].to_string();
for _ in (start.line + 1)..end.line {
str = str + "\n" + iter.next().unwrap().text();
}
let last = iter.next().unwrap();
str = str + "\n" + &last.text()[..end.index];
str
} }
Some(self.buf.text()[sel.text_range()].to_string())
} }
} }
@@ -57,39 +67,29 @@ impl Widget for TextEdit {
painter.layer = base; painter.layer = base;
let region = self.region(); let region = self.region();
let size = vec2(1, self.attrs.line_height); let Some(selection) = self.selection else {
match self.selection { return;
TextSelection::None => (), };
TextSelection::Pos(cursor) => { let layout = self.view.buf.layout();
if let Some(offset) = cursor_pos(cursor, &self.buf) {
painter.primitive_within( // parley reports selection as boxes in layout space, so bidi and
RectPrimitive::color(Color::WHITE), // wrapped lines come out right without this code knowing about either.
size.align(Align::TOP_LEFT).offset(offset).within(&region), for (rect, _) in selection.geometry(layout) {
); let size = vec2(rect.width() as f32, rect.height() as f32);
} let top_left = vec2(rect.x0 as f32, rect.y0 as f32);
} painter.primitive_within(
TextSelection::Span { start, end } => { RectPrimitive::color(Color::SKY),
let (start, end) = sort_cursors(start, end); size.align(Align::TOP_LEFT).offset(top_left).within(&region),
for (l, x, width) in iter_layout_lines(start, end, &self.buf) { );
let top_left = vec2(x, self.attrs.line_height * l as f32);
painter.primitive_within(
RectPrimitive::color(Color::SKY),
size.with_x(width)
.align(Align::TOP_LEFT)
.offset(top_left)
.within(&region),
);
}
if let Some(end_offset) = cursor_pos(end, &self.buf) {
painter.primitive_within(
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT)
.offset(end_offset)
.within(&region),
);
}
}
} }
let caret = selection.focus().geometry(layout, CARET_WIDTH);
let size = vec2(caret.width() as f32, caret.height() as f32);
let top_left = vec2(caret.x0 as f32, caret.y0 as f32);
painter.primitive_within(
RectPrimitive::color(Color::WHITE),
size.align(Align::TOP_LEFT).offset(top_left).within(&region),
);
} }
fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
@@ -101,154 +101,79 @@ impl Widget for TextEdit {
} }
} }
/// provides top left + width const CARET_WIDTH: f32 = 1.0;
fn iter_layout_lines(
start: Cursor,
end: Cursor,
buf: &TextBuffer,
) -> impl Iterator<Item = (usize, f32, f32)> {
gen move {
let mut iter = buf.layout_runs().enumerate();
for (i, line) in iter.by_ref() {
if line.line_i == start.line
&& let Some(start_x) = index_x(&line, start.index)
{
if start.line == end.line
&& let Some(end_x) = index_x(&line, end.index)
{
yield (i, start_x, end_x - start_x);
return;
}
yield (i, start_x, line.line_w - start_x);
break;
}
}
for (i, line) in iter {
if line.line_i > end.line {
return;
}
if line.line_i == end.line
&& let Some(end_x) = index_x(&line, end.index)
{
yield (i, 0.0, end_x);
return;
}
yield (i, 0.0, line.line_w);
}
}
}
/// copied & modified from fn found in Editor in cosmic_text
/// returns x pos of a (non layout) index within an layout run
fn index_x(run: &LayoutRun, index: usize) -> Option<f32> {
for glyph in run.glyphs.iter() {
if index == glyph.start {
return Some(glyph.x);
} else if index > glyph.start && index < glyph.end {
// Guess x offset based on characters
let mut before = 0;
let mut total = 0;
let cluster = &run.text[glyph.start..glyph.end];
for (i, _) in cluster.grapheme_indices(true) {
if glyph.start + i < index {
before += 1;
}
total += 1;
}
let offset = glyph.w * (before as f32) / (total as f32);
return Some(glyph.x + offset);
}
}
None
}
/// returns top of line segment where cursor should visually select
fn cursor_pos(cursor: Cursor, buf: &TextBuffer) -> Option<Vec2> {
let mut prev = None;
for run in buf
.layout_runs()
.skip_while(|r| r.line_i < cursor.line)
.take_while(|r| r.line_i == cursor.line)
{
prev = Some(vec2(run.line_w, run.line_top));
if let Some(pos) = index_x(&run, cursor.index) {
return Some(vec2(pos, run.line_top));
}
}
prev
}
pub struct TextEditCtx<'a> { pub struct TextEditCtx<'a> {
pub text: &'a mut TextEdit, pub text: &'a mut TextEdit,
pub font_system: &'a mut FontSystem, pub data: &'a mut TextData,
} }
impl<'a> TextEditCtx<'a> { impl<'a> TextEditCtx<'a> {
/// The layout, brought up to date with the text first.
///
/// Every cursor movement and hit test goes through parley's layout, so an
/// edit that left it stale would move the caret against the previous text.
/// Shaping is skipped when nothing changed, so calling this freely is fine.
fn layout(&mut self) -> &Layout<UiColor> {
let attrs = self.text.view.attrs.clone();
let width = self.text.view.wrap_width();
self.text.view.buf.shape(self.data, &attrs, width);
self.text.view.buf.layout()
}
/// Keep the selection valid after the text underneath it changed.
fn refresh(&mut self) {
if let Some(sel) = self.text.selection {
let layout = self.layout();
self.text.selection = Some(sel.refresh(layout));
}
}
pub fn take(&mut self) -> String { pub fn take(&mut self) -> String {
let text = self let text = self.text.view.buf.text().to_string();
.text self.set("");
.buf
.lines
.drain(..)
.map(|l| l.into_text())
.collect::<Vec<_>>()
.join("\n");
self.text
.buf
.set_text(self.font_system, "", &Attrs::new(), SHAPING, None);
self.text.selection.clear();
text text
} }
pub fn set(&mut self, text: &str) { pub fn set(&mut self, text: &str) {
let text = self.string(text); let text = self.string(text);
self.text self.text.view.buf.set_text(text);
.buf self.text.view.buf.changed = true;
.set_text(self.font_system, &text, &Attrs::new(), SHAPING, None); self.text.selection = None;
self.text.selection.clear();
} }
pub fn motion(&mut self, motion: Motion, select: bool) { pub fn motion(&mut self, motion: Motion, select: bool) {
if let TextSelection::Pos(cursor) = self.text.selection let Some(sel) = self.text.selection else {
&& let Some(new) = self.buf_motion(cursor, motion) return;
{ };
if select { let layout = self.layout();
self.text.selection = TextSelection::Span { // Collapsing a span with an unshifted left/right puts the caret at the
start: cursor, // near end rather than moving one character from the focus, which is
end: new, // what every other editor does.
}; let sel = if !select && !sel.is_collapsed() {
} else { match motion {
self.text.selection = TextSelection::Pos(new); Motion::Left | Motion::LeftWord => {
} Selection::from(sel.text_range().start_cursor(layout))
} else if let TextSelection::Span { start, end } = self.text.selection {
if select {
if let Some(cursor) = self.buf_motion(end, motion) {
self.text.selection = TextSelection::Span { start, end: cursor };
} }
} else { Motion::Right | Motion::RightWord => {
let (start, end) = sort_cursors(start, end); Selection::from(sel.text_range().end_cursor(layout))
let sel = &mut self.text.selection;
match motion {
Motion::Left | Motion::LeftWord => *sel = TextSelection::Pos(start),
Motion::Right | Motion::RightWord => *sel = TextSelection::Pos(end),
_ => {
if let Some(cursor) = self.buf_motion(end, motion) {
self.text.selection = TextSelection::Pos(cursor);
}
}
} }
_ => apply_motion(sel, layout, motion, false),
} }
} } else {
apply_motion(sel, layout, motion, select)
};
self.text.selection = Some(sel);
} }
/// Replace the `len` characters before the caret. This is the IME's
/// preedit path: it re-sends the whole composition each time.
pub fn replace(&mut self, len: usize, text: &str) { pub fn replace(&mut self, len: usize, text: &str) {
let text = self.string(text); let text = self.string(text);
for _ in 0..len { for _ in 0..len {
self.delete(false); self.backspace(false);
} }
self.insert_inner(&text, false); self.insert_str(&text);
} }
fn string(&self, text: &str) -> String { fn string(&self, text: &str) -> String {
@@ -261,202 +186,174 @@ impl<'a> TextEditCtx<'a> {
pub fn insert(&mut self, text: &str) { pub fn insert(&mut self, text: &str) {
let text = self.string(text); let text = self.string(text);
let mut lines = text.split('\n'); self.insert_str(&text);
let Some(first) = lines.next() else { }
fn insert_str(&mut self, text: &str) {
if text.is_empty() {
return; return;
};
self.insert_inner(first, true);
for line in lines {
self.newline();
self.insert_inner(line, true);
} }
}
pub fn clear_span(&mut self) -> bool {
if let TextSelection::Span { start, end } = self.text.selection {
self.delete_between(start, end);
let (start, _) = sort_cursors(start, end);
self.text.selection = TextSelection::Pos(start);
true
} else {
false
}
}
pub fn delete_between(&mut self, start: Cursor, end: Cursor) {
let lines = &mut self.text.view.buf.lines;
let (start, end) = sort_cursors(start, end);
if start.line == end.line {
let line = &mut lines[start.line];
let text = line.text();
let text = text[..start.index].to_string() + &text[end.index..];
edit_line(line, text);
} else {
// start
let start_text = lines[start.line].text()[..start.index].to_string();
let end_text = &lines[end.line].text()[end.index..];
let text = start_text + end_text;
edit_line(&mut lines[start.line], text);
}
// between
let range = (start.line + 1)..=end.line;
if !range.is_empty() {
lines.splice(range, None);
}
}
fn insert_inner(&mut self, text: &str, mov: bool) {
self.clear_span(); self.clear_span();
if let TextSelection::Pos(cursor) = &mut self.text.selection { let at = match self.text.selection {
let line = &mut self.text.view.buf.lines[cursor.line]; Some(sel) => sel.focus().index(),
let mut line_text = line.text().to_string(); None => return,
line_text.insert_str(cursor.index, text); };
edit_line(line, line_text); let at = at.min(self.text.view.buf.text().len());
if mov { self.text.view.buf.edit().insert_str(at, text);
for _ in 0..text.chars().count() { self.text.view.buf.changed = true;
self.motion(Motion::Right, false); self.set_caret(at + text.len());
} }
}
/// True when there was a span to remove.
pub fn clear_span(&mut self) -> bool {
let Some(sel) = self.text.selection else {
return false;
};
if sel.is_collapsed() {
return false;
} }
let range = sel.text_range();
self.text.view.buf.edit().replace_range(range.clone(), "");
self.text.view.buf.changed = true;
self.set_caret(range.start);
true
}
fn set_caret(&mut self, index: usize) {
let index = index.min(self.text.view.buf.text().len());
let layout = self.layout();
self.text.selection = Some(Selection::from_byte_index(
layout,
index,
Affinity::default(),
));
} }
pub fn newline(&mut self) { pub fn newline(&mut self) {
if self.text.mode == EditMode::SingleLine { if self.text.mode == EditMode::MultiLine {
return; self.insert_str("\n");
}
self.clear_span();
if let TextSelection::Pos(cursor) = &mut self.text.selection {
let lines = &mut self.text.view.buf.lines;
let line = &mut lines[cursor.line];
let new = line.split_off(cursor.index);
cursor.line += 1;
lines.insert(cursor.line, new);
cursor.index = 0;
} }
} }
pub fn backspace(&mut self, word: bool) { pub fn backspace(&mut self, word: bool) {
if !self.clear_span() if self.clear_span() {
&& let TextSelection::Pos(cursor) = &mut self.text.selection return;
&& (cursor.index != 0 || cursor.line != 0)
{
self.motion(if word { Motion::LeftWord } else { Motion::Left }, false);
self.delete(word);
} }
let Some(sel) = self.text.selection else {
return;
};
let end = sel.focus().index();
if end == 0 {
return;
}
let layout = self.layout();
let start = if word {
sel.focus().previous_logical_word(layout).index()
} else {
sel.focus().previous_visual(layout).index()
};
self.delete_range(start, end);
} }
pub fn delete(&mut self, word: bool) { pub fn delete(&mut self, word: bool) {
if !self.clear_span() if self.clear_span() {
&& let TextSelection::Pos(cursor) = &mut self.text.selection return;
{
if word {
let start = *cursor;
if let Some(end) = self.buf_motion(start, Motion::RightWord) {
self.delete_between(start, end);
}
} else {
let lines = &mut self.text.view.buf.lines;
let line = &mut lines[cursor.line];
if cursor.index == line.text().len() {
if cursor.line == lines.len() - 1 {
return;
}
let add = lines.remove(cursor.line + 1).into_text();
let line = &mut lines[cursor.line];
let mut cur = line.text().to_string();
cur.push_str(&add);
edit_line(line, cur);
} else {
let mut text = line.text().to_string();
text.remove(cursor.index);
edit_line(line, text);
}
}
} }
let Some(sel) = self.text.selection else {
return;
};
let start = sel.focus().index();
if start >= self.text.view.buf.text().len() {
return;
}
let layout = self.layout();
let end = if word {
sel.focus().next_logical_word(layout).index()
} else {
sel.focus().next_visual(layout).index()
};
self.delete_range(start, end);
} }
fn buf_motion(&mut self, cursor: Cursor, motion: Motion) -> Option<Cursor> { fn delete_range(&mut self, start: usize, end: usize) {
self.text let len = self.text.view.buf.text().len();
.buf let (start, end) = (start.min(end).min(len), start.max(end).min(len));
.cursor_motion(self.font_system, cursor, None, motion) if start == end {
.map(|r| r.0) return;
}
self.text.view.buf.edit().replace_range(start..end, "");
self.text.view.buf.changed = true;
self.set_caret(start);
} }
pub fn select_word_at(&mut self, cursor: Cursor) { pub fn select_all(&mut self) {
if let (Some(start), Some(end)) = ( let len = self.text.view.buf.text().len();
self.buf_motion(cursor, Motion::LeftWord), if len == 0 {
self.buf_motion(cursor, Motion::RightWord), return;
) {
self.text.selection = TextSelection::Span { start, end };
}
}
pub fn select_line_at(&mut self, cursor: Cursor) {
let end = self.text.buf.lines[cursor.line].text().len();
self.text.selection = TextSelection::Span {
start: Cursor::new(cursor.line, 0),
end: Cursor::new(cursor.line, end),
} }
let layout = self.layout();
let anchor = parley::Cursor::from_byte_index(layout, 0, Affinity::default());
let focus = parley::Cursor::from_byte_index(layout, len, Affinity::default());
self.text.selection = Some(Selection::new(anchor, focus));
} }
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.text.region().top_left().to_abs(size); let pos = pos - self.text.region().top_left().to_abs(size);
let hit = self.text.buf.hit(pos.x, pos.y); let prev_sel = self.text.selection;
let sel = &mut self.text.selection; let prev_hit = self.text.double_hit;
match sel {
TextSelection::None => { // The layout borrows `self`, so the whole decision is made in here and
if !drag && let Some(hit) = hit { // only the answer escapes.
*sel = TextSelection::Pos(hit) let outcome = {
} let layout = self.layout();
let inside =
pos.x >= 0.0 && pos.y >= 0.0 && pos.x <= layout.width() && pos.y <= layout.height();
if !inside {
if drag { None } else { Some((None, None)) }
} else if drag {
prev_sel.map(|sel| (Some(sel.extend_to_point(layout, pos.x, pos.y)), prev_hit))
} else {
let hit = Selection::from_point(layout, pos.x, pos.y);
let index = hit.focus().index();
// A second click in the same place takes the word and a third
// the line; `double_hit` is what remembers that the previous
// click had already grown to a word.
Some(if recent && prev_hit == Some(index) {
(Some(Selection::line_from_point(layout, pos.x, pos.y)), None)
} else if recent && prev_sel.map(|s| s.focus().index()) == Some(index) {
(
Some(Selection::word_from_point(layout, pos.x, pos.y)),
Some(index),
)
} else {
(Some(hit), None)
})
} }
TextSelection::Pos(pos) => match (hit, drag) { };
(None, false) => *sel = TextSelection::None,
(None, true) => (), if let Some((selection, double_hit)) = outcome {
(Some(hit), false) => { self.text.selection = selection;
if recent && hit == *pos { self.text.double_hit = double_hit;
self.text.double_hit = Some(hit);
return self.select_word_at(hit);
} else {
*pos = hit
}
}
(Some(end), true) => *sel = TextSelection::Span { start: *pos, end },
},
TextSelection::Span { start, end } => match (hit, drag) {
(None, false) => *sel = TextSelection::None,
(None, true) => *sel = TextSelection::Pos(*start),
(Some(hit), false) => {
if recent
&& let Some(double) = self.text.double_hit
&& double == hit
{
return self.select_line_at(hit);
} else {
*sel = TextSelection::Pos(hit)
}
}
(Some(hit), true) => *end = hit,
},
}
if let TextSelection::Span { start, end } = sel
&& start == end
{
*sel = TextSelection::Pos(*start);
} }
} }
pub fn deselect(&mut self) { pub fn deselect(&mut self) {
self.text.selection = TextSelection::None; self.text.selection = None;
self.text.double_hit = None;
} }
pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult { pub fn apply_event(&mut self, event: &KeyEvent, modifiers: &Modifiers) -> TextInputResult {
let old = (self.text.content(), self.text.selection); let old = (self.text.view.buf.text().to_string(), self.text.selection);
let mut undo = false; let mut undo = false;
let res = self.apply_event_inner(event, modifiers, &mut undo); let res = self.apply_event_inner(event, modifiers, &mut undo);
if undo && let Some((old, selection)) = self.text.history.pop() { if undo {
self.set(&old); if let Some((old, selection)) = self.text.history.pop() {
self.text.selection = selection; self.set(&old);
} else if self.text.content() != old.0 { self.text.selection = selection;
self.refresh();
}
} else if self.text.view.buf.text() != old.0 {
self.text.history.push(old); self.text.history.push(old);
} }
res res
@@ -481,21 +378,25 @@ impl<'a> TextEditCtx<'a> {
} }
} }
NamedKey::ArrowRight => { NamedKey::ArrowRight => {
if modifiers.control { let motion = if modifiers.control {
self.motion(Motion::RightWord, modifiers.shift) Motion::RightWord
} else { } else {
self.motion(Motion::Right, modifiers.shift) Motion::Right
} };
self.motion(motion, modifiers.shift);
} }
NamedKey::ArrowLeft => { NamedKey::ArrowLeft => {
if modifiers.control { let motion = if modifiers.control {
self.motion(Motion::LeftWord, modifiers.shift) Motion::LeftWord
} else { } else {
self.motion(Motion::Left, modifiers.shift) Motion::Left
} };
self.motion(motion, modifiers.shift);
} }
NamedKey::ArrowUp => self.motion(Motion::Up, modifiers.shift), NamedKey::ArrowUp => self.motion(Motion::Up, modifiers.shift),
NamedKey::ArrowDown => self.motion(Motion::Down, modifiers.shift), NamedKey::ArrowDown => self.motion(Motion::Down, modifiers.shift),
NamedKey::Home => self.motion(Motion::LineStart, modifiers.shift),
NamedKey::End => self.motion(Motion::LineEnd, modifiers.shift),
NamedKey::Escape => { NamedKey::Escape => {
self.deselect(); self.deselect();
return TextInputResult::Unfocus; return TextInputResult::Unfocus;
@@ -507,34 +408,18 @@ impl<'a> TextEditCtx<'a> {
match text.as_str() { match text.as_str() {
"v" => return TextInputResult::Paste, "v" => return TextInputResult::Paste,
"c" => { "c" => {
if let TextSelection::Span { start, end } = self.text.selection { if let Some(content) = self.text.selected_text() {
let content = self.text.select_content(start, end);
return TextInputResult::Copy(content); return TextInputResult::Copy(content);
} }
} }
"x" => { "x" => {
if let TextSelection::Span { start, end } = self.text.selection { if let Some(content) = self.text.selected_text() {
let content = self.text.select_content(start, end);
self.clear_span(); self.clear_span();
return TextInputResult::Copy(content); return TextInputResult::Copy(content);
} }
} }
"a" => { "a" => self.select_all(),
if !self.text.buf.lines[0].text().is_empty() "z" => *undo = true,
|| self.text.buf.lines.len() > 1
{
let lines = &self.text.buf.lines;
let last_line = lines.len() - 1;
let last_idx = lines[last_line].text().len();
self.text.selection = TextSelection::Span {
start: Cursor::new(0, 0),
end: Cursor::new(last_line, last_idx),
};
}
}
"z" => {
*undo = true;
}
_ => self.insert(text), _ => self.insert(text),
} }
} else { } else {
@@ -547,6 +432,40 @@ impl<'a> TextEditCtx<'a> {
} }
} }
fn apply_motion(
sel: Selection,
layout: &Layout<UiColor>,
motion: Motion,
extend: bool,
) -> Selection {
match motion {
Motion::Left => sel.previous_visual(layout, extend),
Motion::Right => sel.next_visual(layout, extend),
Motion::LeftWord => sel.previous_visual_word(layout, extend),
Motion::RightWord => sel.next_visual_word(layout, extend),
Motion::Up => sel.previous_line(layout, extend),
Motion::Down => sel.next_line(layout, extend),
Motion::LineStart => sel.line_start(layout, extend),
Motion::LineEnd => sel.line_end(layout, extend),
}
}
/// The ends of a byte range as cursors, so collapsing a selection can put the
/// caret at whichever end the movement asked for.
trait RangeCursors {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor;
}
impl RangeCursors for std::ops::Range<usize> {
fn start_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.start, Affinity::default())
}
fn end_cursor(&self, layout: &Layout<UiColor>) -> parley::Cursor {
parley::Cursor::from_byte_index(layout, self.end, Affinity::default())
}
}
#[derive(Default)] #[derive(Default)]
pub struct Modifiers { pub struct Modifiers {
pub shift: bool, pub shift: bool,
@@ -569,33 +488,6 @@ pub enum TextInputResult {
Paste, Paste,
} }
#[derive(Debug, Default, Clone, Copy)]
pub enum TextSelection {
#[default]
None,
Pos(Cursor),
Span {
start: Cursor,
end: Cursor,
},
}
impl TextSelection {
pub fn clear(&mut self) {
match self {
TextSelection::None => (),
TextSelection::Pos(cursor) => {
cursor.line = 0;
cursor.index = 0;
cursor.affinity = Affinity::default();
}
TextSelection::Span { start: _, end: _ } => {
*self = TextSelection::None;
}
}
}
}
impl TextInputResult { impl TextInputResult {
pub fn unfocus(&self) -> bool { pub fn unfocus(&self) -> bool {
matches!(self, TextInputResult::Unfocus) matches!(self, TextInputResult::Unfocus)
@@ -625,7 +517,7 @@ impl<I: IdLike<Widget = TextEdit>> TextEditable for I {
let ui = ui.ui_mut(); let ui = ui.ui_mut();
TextEditCtx { TextEditCtx {
text: ui.widgets.get_mut(self).unwrap(), text: ui.widgets.get_mut(self).unwrap(),
font_system: &mut ui.text.font_system, data: &mut ui.text,
} }
} }
} }
+28 -55
View File
@@ -6,11 +6,8 @@ pub use edit::*;
use iris_core::util::MutDetect; use iris_core::util::MutDetect;
use crate::prelude::*; use crate::prelude::*;
use cosmic_text::{Attrs, BufferLine, Cursor, Metrics, Shaping};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
pub const SHAPING: Shaping = Shaping::Advanced;
pub struct Text { pub struct Text {
pub content: MutDetect<String>, pub content: MutDetect<String>,
view: TextView, view: TextView,
@@ -25,6 +22,18 @@ pub struct TextView {
pub hint: Option<StrongWidget>, pub hint: Option<StrongWidget>,
} }
impl TextView {
fn is_blank(&self) -> bool {
self.buf.is_empty()
}
/// The width the text was last laid out against, so an editor asking for
/// the layout gets the same wrapping the last draw used.
pub fn wrap_width(&self) -> Option<f32> {
self.width
}
}
impl TextView { impl TextView {
pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self { pub fn new(buf: TextBuffer, attrs: TextAttrs, hint: Option<StrongWidget>) -> Self {
Self { Self {
@@ -45,15 +54,6 @@ impl TextView {
.align(self.align) .align(self.align)
} }
fn tex_region(&self, tex: &RenderedText) -> UiRegion {
let region = tex.size.align(self.align);
let dims = tex.handle.size();
let mut region = region.offset(tex.top_left_offset);
region.x.end = region.x.start + UiScalar::abs(dims.x);
region.y.end = region.y.start + UiScalar::abs(dims.y);
region
}
fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText { fn render(&mut self, ctx: &mut SizeCtx) -> RenderedText {
let width = if self.attrs.wrap { let width = if self.attrs.wrap {
Some(ctx.px_size().x) Some(ctx.px_size().x)
@@ -68,10 +68,7 @@ impl TextView {
return tex.clone(); return tex.clone();
} }
self.width = width; self.width = width;
let font_system = &mut ctx.text.font_system; let tex = ctx.draw_text(&mut self.buf, &self.attrs, width);
self.attrs.apply(font_system, &mut self.buf, width);
self.buf.shape_until_scroll(font_system, false);
let tex = ctx.draw_text(&mut self.buf, &self.attrs);
self.tex = Some(tex.clone()); self.tex = Some(tex.clone());
self.attrs.changed = false; self.attrs.changed = false;
self.buf.changed = false; self.buf.changed = false;
@@ -81,9 +78,8 @@ impl TextView {
self.tex.as_ref() self.tex.as_ref()
} }
pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len { pub fn desired_width(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(hint) = &self.hint if self.is_blank()
&& let [line] = &self.buf.lines[..] && let Some(hint) = &self.hint
&& line.text().is_empty()
{ {
ctx.width(hint) ctx.width(hint)
} else { } else {
@@ -91,9 +87,8 @@ impl TextView {
} }
} }
pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len { pub fn desired_height(&mut self, ctx: &mut SizeCtx) -> Len {
if let Some(hint) = &self.hint if self.is_blank()
&& let [line] = &self.buf.lines[..] && let Some(hint) = &self.hint
&& line.text().is_empty()
{ {
ctx.height(hint) ctx.height(hint)
} else { } else {
@@ -102,47 +97,35 @@ impl TextView {
} }
pub fn draw(&mut self, painter: &mut Painter) -> UiRegion { pub fn draw(&mut self, painter: &mut Painter) -> UiRegion {
let tex = self.render(&mut painter.size_ctx()); let tex = self.render(&mut painter.size_ctx());
let region = self.tex_region(&tex); let region = tex.size.align(self.align);
if let Some(hint) = &self.hint if self.is_blank()
&& let [line] = &self.buf.lines[..] && let Some(hint) = &self.hint
&& line.text().is_empty()
{ {
painter.widget(hint); painter.widget(hint);
} else { } else {
painter.texture_within(&tex.handle, region); let within = region.within(&painter.region());
painter.glyphs(&tex, within);
} }
region region
} }
pub fn content(&self) -> String { pub fn content(&self) -> String {
self.buf self.buf.text().to_string()
.lines
.iter()
.map(|l| l.text())
.collect::<Vec<_>>()
.join("\n")
} }
} }
impl Text { impl Text {
pub fn new(content: impl Into<String>) -> Self { pub fn new(content: impl Into<String>) -> Self {
let attrs = TextAttrs::default(); let content: String = content.into();
let buf = TextBuffer::new_empty(Metrics::new(attrs.font_size, attrs.line_height));
Self { Self {
content: content.into().into(), view: TextView::new(TextBuffer::new(&content), TextAttrs::default(), None),
view: TextView::new(buf, attrs, None), content: content.into(),
} }
} }
fn update_buf(&mut self, ctx: &mut SizeCtx) { fn update_buf(&mut self, _ctx: &mut SizeCtx) {
if self.content.changed { if self.content.changed {
self.content.changed = false; self.content.changed = false;
self.view.buf.set_text( self.view.buf.set_text(self.content.as_str());
&mut ctx.text.font_system,
&self.content,
&Attrs::new().family(self.view.attrs.family),
SHAPING,
None,
);
} }
} }
} }
@@ -164,16 +147,6 @@ impl Widget for Text {
} }
} }
pub fn sort_cursors(a: Cursor, b: Cursor) -> (Cursor, Cursor) {
let start = a.min(b);
let end = a.max(b);
(start, end)
}
pub fn edit_line(line: &mut BufferLine, text: String) {
line.set_text(text, line.ending(), line.attrs_list().clone());
}
impl Deref for Text { impl Deref for Text {
type Target = TextAttrs; type Target = TextAttrs;