diff --git a/Cargo.lock b/Cargo.lock index c42eaf2..8adaef6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -124,9 +124,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.61" +version = "0.1.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "705339e0e4a9690e2908d2b3d049d85682cf19fbd5782494498fbf7003a6a282" +checksum = "eff18d764974428cf3a9328e23fc5c986f5fbed46e6cd4cdf42544df5d297ec1" dependencies = [ "proc-macro2", "quote", @@ -229,9 +229,9 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.11.1" +version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" +checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" [[package]] name = "byteorder" @@ -333,9 +333,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.1.0" +version = "4.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa91278560fc226a5d9d736cc21e485ff9aad47d26b8ffe1f54cba868b684b9f" +checksum = "0e638668a62aced2c9fb72b5135a33b4a500485ccf2a0e402e09aa04ab2fc115" dependencies = [ "bitflags", "clap_derive", @@ -487,6 +487,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "css-color-parser" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ccb6ce7ef97e6dc6e575e51b596c9889a5cc88a307b5ef177d215c61fd7581d" +dependencies = [ + "lazy_static 0.1.16", +] + [[package]] name = "ctr" version = "0.9.2" @@ -512,9 +521,9 @@ dependencies = [ [[package]] name = "cxx" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d1075c37807dcf850c379432f0df05ba52cc30f279c5cfc43cc221ce7f8579" +checksum = "b61a7545f753a88bcbe0a70de1fcc0221e10bfc752f576754fa91e663db1622e" dependencies = [ "cc", "cxxbridge-flags", @@ -524,9 +533,9 @@ dependencies = [ [[package]] name = "cxx-build" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5044281f61b27bc598f2f6647d480aed48d2bf52d6eb0b627d84c0361b17aa70" +checksum = "f464457d494b5ed6905c63b0c4704842aba319084a0a3561cdc1359536b53200" dependencies = [ "cc", "codespan-reporting", @@ -539,15 +548,15 @@ dependencies = [ [[package]] name = "cxxbridge-flags" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61b50bc93ba22c27b0d31128d2d130a0a6b3d267ae27ef7e4fae2167dfe8781c" +checksum = "43c7119ce3a3701ed81aca8410b9acf6fc399d2629d057b87e2efa4e63a3aaea" [[package]] name = "cxxbridge-macro" -version = "1.0.86" +version = "1.0.87" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39e61fda7e62115119469c7b3591fd913ecca96fb766cfd3f2e2502ab7bc87a5" +checksum = "65e07508b90551e610910fa648a1878991d367064997a596135b86df30daf07e" dependencies = [ "proc-macro2", "quote", @@ -714,9 +723,9 @@ dependencies = [ [[package]] name = "ed25519" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e9c280362032ea4203659fc489832d0204ef09f247a0506f170dafcac08c369" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" dependencies = [ "serde", "signature", @@ -819,6 +828,16 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2022715d62ab30faffd124d40b76f4134a550a87792276512b18d63272333394" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.25" @@ -1052,6 +1071,20 @@ dependencies = [ "digest 0.10.6", ] +[[package]] +name = "html5ever" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "http" version = "0.2.8" @@ -1129,10 +1162,12 @@ version = "0.0.3" dependencies = [ "chrono", "clap", + "css-color-parser", "dirs", - "futures", "gethostname", - "lazy_static", + "html5ever", + "lazy_static 1.4.0", + "markup5ever_rcdom", "matrix-sdk", "mime", "mime_guess", @@ -1141,7 +1176,6 @@ dependencies = [ "rpassword", "serde", "serde_json", - "sled", "thiserror", "tokio", "tracing", @@ -1318,6 +1352,12 @@ dependencies = [ "serde", ] +[[package]] +name = "lazy_static" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf186d1a8aa5f5bee5fd662bc9c1b949e0259e1bcc379d1f006847b0080c7417" + [[package]] name = "lazy_static" version = "1.4.0" @@ -1373,12 +1413,44 @@ dependencies = [ "hashbrown", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "maplit" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "markup5ever" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2629bb1404f3d34c2e921f21fd34ba00b206124c81f65c50b43b6aaefeb016" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9521dd6750f8e80ee6c53d65e2e4656d7de37064f3a7a5d2d11d05df93839c2" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "matrix-sdk" version = "0.6.2" @@ -1609,9 +1681,9 @@ dependencies = [ [[package]] name = "modalkit" -version = "0.0.9" +version = "0.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28a676fc7ab6a9fd329ff82d9d291370aafcf904ac3ff9f72397f64529cb1b2d" +checksum = "4f57d0d53c9f3d8cad2508351f88656e4185cbb8b95d0c738b314fc8167bc90f" dependencies = [ "anymap2", "bitflags", @@ -1627,10 +1699,16 @@ dependencies = [ ] [[package]] -name = "nom" -version = "7.1.2" +name = "new_debug_unreachable" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5507769c4919c998e69e49c839d9dc6e693ede4cc4290d6ad8b41d4f09c548c" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" dependencies = [ "memchr", "minimal-lexical", @@ -1782,6 +1860,44 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.0.12" @@ -1841,6 +1957,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro-crate" version = "1.2.1" @@ -1878,9 +2000,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.49" +version = "1.0.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57a8eca9f9c4ffde41714334dee777596264c7825420f521abc92b5b5deb63a5" +checksum = "6ef7d57beacfaf2d8aee5937dab7b7f28de3cb8b1828479bb5de2a7106f2bae2" dependencies = [ "unicode-ident", ] @@ -1908,6 +2030,17 @@ dependencies = [ "syn", ] +[[package]] +name = "pulldown-cmark" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d9cc634bc78768157b5cbfe988ffcd1dcba95cd2b2f03a88316c08c6d00ed63" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + [[package]] name = "quote" version = "1.0.23" @@ -2027,11 +2160,11 @@ checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" [[package]] name = "reqwest" -version = "0.11.13" +version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68cc60575865c7831548863cc02356512e3f1dc2f3f82cb837d7fc4cc8f3c97c" +checksum = "21eed90ec8570952d53b772ecf8f206aa1ec9a3d76b2521c56c42973f2d91ee9" dependencies = [ - "base64 0.13.1", + "base64 0.21.0", "bytes", "encoding_rs", "futures-core", @@ -2158,6 +2291,7 @@ dependencies = [ "js_int", "js_option", "percent-encoding", + "pulldown-cmark", "rand 0.8.5", "regex", "ruma-identifiers-validation", @@ -2220,9 +2354,9 @@ dependencies = [ [[package]] name = "rustix" -version = "0.36.6" +version = "0.36.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4feacf7db682c6c329c4ede12649cd36ecab0f3be5b7d74e6a20304725db4549" +checksum = "d4fdebc4b395b7fbb9ab11e462e20ed9051e7b16e42d24042c776eca0ac81b03" dependencies = [ "bitflags", "errno", @@ -2369,7 +2503,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "900fba806f70c630b0a382d0d825e17a0f19fcd059a2ade1ff237bcddf446b31" dependencies = [ - "lazy_static", + "lazy_static 1.4.0", ] [[package]] @@ -2408,6 +2542,12 @@ version = "1.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" +[[package]] +name = "siphasher" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bd3e3206899af3f8b12af284fafc038cc1dc2b41d1b89dd17297221c5d225de" + [[package]] name = "slab" version = "0.4.7" @@ -2470,6 +2610,32 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f026164926842ec52deb1938fae44f83dfdb82d0a5b0270c5bd5935ab74d6dd" +[[package]] +name = "string_cache" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213494b7a2b503146286049378ce02b482200519accc31872ee8be91fa820a08" +dependencies = [ + "new_debug_unreachable", + "once_cell", + "parking_lot 0.12.1", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb30289b722be4ff74a408c3cc27edeaad656e06cb1fe8fa9231fa59c728988" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.10.0" @@ -2506,10 +2672,21 @@ dependencies = [ ] [[package]] -name = "termcolor" -version = "1.1.3" +name = "tendril" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bab24d30b911b2376f3a13cc2cd443142f0c81dda04c118693e35b3835757755" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "termcolor" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be55cf8942feac5c765c2c993422806843c9a9a45d4d5c407ad6dd2ea95eb9b6" dependencies = [ "winapi-util", ] @@ -2598,9 +2775,9 @@ checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" [[package]] name = "tokio" -version = "1.24.1" +version = "1.24.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9f76183f91ecfb55e1d7d5602bd1d979e38a3a522fe900241cf195624d67ae" +checksum = "597a12a59981d9e3c38d216785b0c37399f6e415e8d0712047620f189371b0bb" dependencies = [ "autocfg", "bytes", @@ -2652,9 +2829,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.5.10" +version = "0.5.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1333c76748e868a4d9d1017b5ab53171dfd095f70c712fdb4653a406547f598f" +checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234" dependencies = [ "serde", ] @@ -2715,7 +2892,7 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78ddad33d2d10b1ed7eb9d1f518a5674713876e97e5bb9b7345a7984fbb4f922" dependencies = [ - "lazy_static", + "lazy_static 1.4.0", "log", "tracing-core", ] @@ -2770,9 +2947,9 @@ dependencies = [ [[package]] name = "unicode-bidi" -version = "0.3.8" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099b7128301d285f79ddd55b9a83d5e6b9e97c92e0ea0daebee7263e932de992" +checksum = "d54675592c1dbefd78cbd98db9bacd89886e1ca50692a0692baefffdeb92dd58" [[package]] name = "unicode-ident" @@ -2835,6 +3012,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "0.8.2" @@ -3167,6 +3350,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "xml5ever" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4034e1d05af98b51ad7214527730626f019682d797ba38b51689212118d8e650" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "zeroize" version = "1.3.0" diff --git a/Cargo.toml b/Cargo.toml index 3fb96e7..4d56ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,25 +10,24 @@ description = "A Matrix chat client that uses Vim keybindings" license = "Apache-2.0" exclude = [".github", "CONTRIBUTING.md"] keywords = ["matrix", "chat", "tui", "vim"] +categories = ["command-line-utilities"] rust-version = "1.66" [dependencies] chrono = "0.4" clap = {version = "4.0", features = ["derive"]} +css-color-parser = "0.1.2" dirs = "4.0.0" -futures = "0.3.21" gethostname = "0.4.1" -matrix-sdk = {version = "0.6", default-features = false, features = ["e2e-encryption", "sled", "rustls-tls"]} +html5ever = "0.26.0" +markup5ever_rcdom = "0.2.0" mime = "^0.3.16" mime_guess = "^2.0.4" -modalkit = "0.0.9" regex = "^1.5" rpassword = "^7.2" serde = "^1.0" serde_json = "^1.0" -sled = "0.34" thiserror = "^1.0.37" -tokio = {version = "1.24.1", features = ["macros", "net", "rt-multi-thread", "sync", "time"]} tracing = "~0.1.36" tracing-appender = "~0.2.2" tracing-subscriber = "0.3.16" @@ -36,5 +35,17 @@ unicode-segmentation = "^1.7" unicode-width = "0.1.10" url = {version = "^2.2.2", features = ["serde"]} +[dependencies.modalkit] +version = "0.0.10" + +[dependencies.matrix-sdk] +version = "0.6" +default-features = false +features = ["e2e-encryption", "markdown", "sled", "rustls-tls"] + +[dependencies.tokio] +version = "1.24.1" +features = ["macros", "net", "rt-multi-thread", "sync", "time"] + [dev-dependencies] lazy_static = "1.4.0" diff --git a/README.md b/README.md index 532ca44..ebc9623 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ website, [iamb.chat]. Install Rust and Cargo, and then run: ``` -cargo install iamb +cargo install --locked iamb ``` ## Configuration @@ -66,9 +66,9 @@ two other TUI clients and Element Web: | Attachment uploading | ✔️ | ❌ | ✔️ | ✔️ | | Attachment downloading | ✔️ | ✔️ | ✔️ | ✔️ | | Send stickers | ❌ | ❌ | ❌ | ✔️ | -| Send formatted messages (markdown) | ❌ ([#10]) | ✔️ | ✔️ | ✔️ | +| Send formatted messages (markdown) | ✔️ | ✔️ | ✔️ | ✔️ | | Rich Text Editor for formatted messages | ❌ | ❌ | ❌ | ✔️ | -| Display formatted messages | ❌ ([#10]) | ✔️ | ✔️ | ✔️ | +| Display formatted messages | ✔️ | ✔️ | ✔️ | ✔️ | | Redacting | ✔️ | ✔️ | ✔️ | ✔️ | | Multiple Matrix Accounts | ✔️ | ❌ | ✔️ | ❌ | | New user registration | ❌ | ❌ | ❌ | ✔️ | diff --git a/src/base.rs b/src/base.rs index 9198931..f30683e 100644 --- a/src/base.rs +++ b/src/base.rs @@ -59,7 +59,7 @@ use crate::{ ApplicationSettings, }; -const ROOM_FETCH_DEBOUNCE: Duration = Duration::from_secs(3); +const ROOM_FETCH_DEBOUNCE: Duration = Duration::from_secs(2); #[derive(Clone, Debug, Eq, PartialEq)] pub enum IambInfo {} diff --git a/src/main.rs b/src/main.rs index 0e485c2..f823194 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,6 +42,7 @@ mod commands; mod config; mod keybindings; mod message; +mod util; mod windows; mod worker; @@ -99,6 +100,14 @@ use modalkit::{ }, }; +const MIN_MSG_LOAD: u32 = 50; + +fn msg_load_req(area: Rect) -> u32 { + let n = area.height as u32; + + n.max(MIN_MSG_LOAD) +} + struct Application { store: AsyncProgramStore, worker: Requester, @@ -176,7 +185,7 @@ impl Application { f.set_cursor(cx, cy); } - store.application.load_older(area.height as u32); + store.application.load_older(msg_load_req(area)); })?; Ok(()) @@ -186,7 +195,8 @@ impl Application { loop { self.redraw(false, self.store.clone().lock().await.deref_mut())?; - if !poll(Duration::from_millis(500))? { + if !poll(Duration::from_secs(1))? { + // Redraw in case there's new messages to show. continue; } diff --git a/src/message/html.rs b/src/message/html.rs new file mode 100644 index 0000000..d006269 --- /dev/null +++ b/src/message/html.rs @@ -0,0 +1,942 @@ +//! # Rendering for formatted bodies +//! +//! This module contains the code for rendering messages that contained an +//! "org.matrix.custom.html"-formatted body. +//! +//! The Matrix specification recommends limiting rendered tags and attributes to a safe subset of +//! HTML. You can read more in section 11.2.1.1, "m.room.message msgtypes": +//! +//! https://spec.matrix.org/unstable/client-server-api/#mroommessage-msgtypes +//! +//! This isn't as important for iamb, since it isn't a browser environment, but we do still map +//! input onto an enum of the safe list of tags to keep it easy to understand and process. +use std::ops::Deref; + +use css_color_parser::Color as CssColor; +use markup5ever_rcdom::{Handle, NodeData, RcDom}; +use unicode_segmentation::UnicodeSegmentation; + +use html5ever::{ + driver::{parse_fragment, ParseOpts}, + interface::{Attribute, QualName}, + local_name, + namespace_url, + ns, + tendril::{StrTendril, TendrilSink}, +}; + +use modalkit::tui::{ + layout::Alignment, + style::{Color, Modifier as StyleModifier, Style}, + symbols::line, + text::{Span, Spans, Text}, +}; + +use crate::{ + message::printer::TextPrinter, + util::{join_cell_text, space_text}, +}; + +struct BulletIterator { + style: ListStyle, + pos: usize, + len: usize, +} + +impl BulletIterator { + fn width(&self) -> usize { + match self.style { + ListStyle::Unordered => 2, + ListStyle::Ordered => self.len.to_string().len() + 2, + } + } +} + +impl Iterator for BulletIterator { + type Item = String; + + fn next(&mut self) -> Option { + if self.pos == self.len { + return None; + } + + self.pos += 1; + + let bullet = match self.style { + ListStyle::Unordered => "- ".to_string(), + ListStyle::Ordered => { + let w = self.len.to_string().len(); + format!("{: >w$}. ", self.pos, w = w) + }, + }; + + return Some(bullet); + } +} + +#[derive(Clone, Copy, Debug)] +pub enum ListStyle { + Ordered, + Unordered, +} + +impl ListStyle { + fn bullets(&self, len: usize) -> BulletIterator { + BulletIterator { style: *self, pos: 0, len } + } +} + +pub type StyleTreeChildren = Vec; + +pub enum CellType { + Data, + Header, +} + +pub struct TableRow { + cells: Vec<(CellType, StyleTreeNode)>, +} + +impl TableRow { + fn columns(&self) -> usize { + self.cells.len() + } +} + +pub struct TableSection { + rows: Vec, +} + +impl TableSection { + fn columns(&self) -> usize { + self.rows.iter().map(TableRow::columns).max().unwrap_or(0) + } +} + +pub struct Table { + caption: Option>, + sections: Vec, +} + +impl Table { + fn columns(&self) -> usize { + self.sections.iter().map(TableSection::columns).max().unwrap_or(0) + } + + fn to_text(&self, width: usize, style: Style) -> Text { + let mut text = Text::default(); + let columns = self.columns(); + let cell_total = width.saturating_sub(columns).saturating_sub(1); + let cell_min = cell_total / columns; + let mut cell_slop = cell_total - cell_min * columns; + let cell_widths = (0..columns) + .into_iter() + .map(|_| { + let slopped = cell_slop.min(1); + cell_slop -= slopped; + cell_min + slopped + }) + .collect::>(); + + let mut nrows = 0; + + if let Some(caption) = &self.caption { + let subw = width.saturating_sub(6); + let mut printer = TextPrinter::new(subw, style, true).align(Alignment::Center); + caption.print(&mut printer, style); + + for mut line in printer.finish().lines { + line.0.insert(0, Span::styled(" ", style)); + line.0.push(Span::styled(" ", style)); + text.lines.push(line); + } + } + + for section in self.sections.iter() { + for row in section.rows.iter() { + let mut ruler = String::new(); + + for (i, w) in cell_widths.iter().enumerate() { + let cross = match (nrows, i) { + (0, 0) => line::TOP_LEFT, + (0, _) => line::HORIZONTAL_DOWN, + (_, 0) => line::VERTICAL_RIGHT, + (_, _) => line::CROSS, + }; + + ruler.push_str(cross); + + for _ in 0..*w { + ruler.push_str(line::HORIZONTAL); + } + } + + if nrows == 0 { + ruler.push_str(line::TOP_RIGHT); + } else { + ruler.push_str(line::VERTICAL_LEFT); + } + + text.lines.push(Spans(vec![Span::styled(ruler, style)])); + + let cells = cell_widths + .iter() + .enumerate() + .map(|(i, w)| { + let text = if let Some((kind, cell)) = row.cells.get(i) { + let style = match kind { + CellType::Header => style.add_modifier(StyleModifier::BOLD), + CellType::Data => style, + }; + + cell.to_text(*w, style) + } else { + space_text(*w, style) + }; + + (text, *w) + }) + .collect(); + + let joined = join_cell_text(cells, Span::styled(line::VERTICAL, style)); + text.lines.extend(joined.lines); + + nrows += 1; + } + } + + if nrows > 0 { + let mut ruler = String::new(); + + for (i, w) in cell_widths.iter().enumerate() { + let cross = if i == 0 { + line::BOTTOM_LEFT + } else { + line::HORIZONTAL_UP + }; + + ruler.push_str(cross); + + for _ in 0..*w { + ruler.push_str(line::HORIZONTAL); + } + } + + ruler.push_str(line::BOTTOM_RIGHT); + text.lines.push(Spans(vec![Span::styled(ruler, style)])); + } + + text + } +} + +pub enum StyleTreeNode { + Blockquote(Box), + Break, + Code(Box, Option), + Header(Box, usize), + Image(Option), + List(StyleTreeChildren, ListStyle), + Paragraph(Box), + Reply(Box), + Ruler, + Style(Box, Style), + Table(Table), + Text(String), + Sequence(StyleTreeChildren), +} + +impl StyleTreeNode { + pub fn to_text(&self, width: usize, style: Style) -> Text { + let mut printer = TextPrinter::new(width, style, true); + self.print(&mut printer, style); + printer.finish() + } + + pub fn print<'a>(&'a self, printer: &mut TextPrinter<'a>, style: Style) { + let width = printer.width(); + + match self { + StyleTreeNode::Blockquote(child) => { + let mut subp = printer.sub(4); + child.print(&mut subp, style); + + for mut line in subp.finish() { + line.0.insert(0, Span::styled(" ", style)); + printer.push_line(line); + } + }, + StyleTreeNode::Code(child, _) => { + child.print(printer, style); + }, + StyleTreeNode::Header(child, level) => { + let style = style.add_modifier(StyleModifier::BOLD); + let mut hashes = "#".repeat(*level); + hashes.push(' '); + + printer.push_str(hashes, style); + child.print(printer, style); + }, + StyleTreeNode::Image(None) => {}, + StyleTreeNode::Image(Some(alt)) => { + printer.commit(); + printer.push_str("Image Alt: ", Style::default()); + printer.push_str(alt, Style::default()); + printer.commit(); + }, + StyleTreeNode::List(children, lt) => { + let mut bullets = lt.bullets(children.len()); + let liw = bullets.width(); + + for child in children { + let mut subp = printer.sub(liw); + let mut bullet = bullets.next(); + child.print(&mut subp, style); + + for mut line in subp.finish() { + let leading = if let Some(bullet) = bullet.take() { + Span::styled(bullet, style) + } else { + Span::styled(" ".repeat(liw), style) + }; + + line.0.insert(0, leading); + printer.push_line(line); + } + } + }, + StyleTreeNode::Paragraph(child) => { + printer.push_break(); + child.print(printer, style); + printer.commit(); + }, + StyleTreeNode::Reply(child) => { + if printer.hide_reply() { + return; + } + + printer.push_break(); + child.print(printer, style); + printer.commit(); + }, + StyleTreeNode::Ruler => { + printer.push_str(line::HORIZONTAL.repeat(width), style); + }, + StyleTreeNode::Table(table) => { + let text = table.to_text(width, style); + printer.push_text(text); + }, + StyleTreeNode::Break => { + printer.push_break(); + }, + StyleTreeNode::Text(s) => { + printer.push_str(s.as_str(), style); + }, + + StyleTreeNode::Style(child, patch) => child.print(printer, style.patch(*patch)), + StyleTreeNode::Sequence(children) => { + for child in children { + child.print(printer, style); + } + }, + } + } +} + +pub struct StyleTree { + children: StyleTreeChildren, +} + +impl StyleTree { + pub fn to_text(&self, width: usize, style: Style, hide_reply: bool) -> Text<'_> { + let mut printer = TextPrinter::new(width, style, hide_reply); + + for child in self.children.iter() { + child.print(&mut printer, style); + } + + printer.finish() + } +} + +fn c2c(handles: &[Handle]) -> Vec { + handles.iter().flat_map(h2t).collect() +} + +fn c2t(handles: &[Handle]) -> Box { + let node = StyleTreeNode::Sequence(c2c(handles)); + + Box::new(node) +} + +fn get_node(hdl: &Handle, want: &str) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local.as_ref() != want { + return None; + } + + let c = c2c(&node.children.borrow()); + return Some(StyleTreeNode::Sequence(c)); + } else { + return None; + } +} + +fn li2t(hdl: &Handle) -> Option { + get_node(hdl, "li") +} + +fn table_cell(hdl: &Handle) -> Option<(CellType, StyleTreeNode)> { + if let Some(node) = get_node(hdl, "th") { + return Some((CellType::Header, node)); + } + + Some((CellType::Data, get_node(hdl, "td")?)) +} + +fn table_row(hdl: &Handle) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + if name.local.as_ref() != "tr" { + return None; + } + + let cells = table_cells(&node.children.borrow()); + return Some(TableRow { cells }); + } else { + return None; + } +} + +fn table_section(hdl: &Handle) -> Option { + let node = hdl.deref(); + + if let NodeData::Element { name, .. } = &node.data { + match name.local.as_ref() { + "thead" | "tbody" => { + let rows = table_rows(&node.children.borrow()); + + Some(TableSection { rows }) + }, + _ => None, + } + } else { + return None; + } +} + +fn table_cells(handles: &[Handle]) -> Vec<(CellType, StyleTreeNode)> { + handles.iter().filter_map(table_cell).collect() +} + +fn table_rows(handles: &[Handle]) -> Vec { + handles.iter().filter_map(table_row).collect() +} + +fn table_sections(handles: &[Handle]) -> Vec { + handles.iter().filter_map(table_section).collect() +} + +fn lic2t(handles: &[Handle]) -> StyleTreeChildren { + handles.iter().filter_map(li2t).collect() +} + +fn attrs_to_alt(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "alt" { + continue; + } + + return Some(attr.value.to_string()); + } + + return None; +} + +fn attrs_to_language(attrs: &[Attribute]) -> Option { + for attr in attrs { + if attr.name.local.as_ref() != "class" { + continue; + } + + for class in attr.value.as_ref().unicode_words() { + if class.len() > 9 && class.starts_with("language-") { + return Some(class[9..].to_string()); + } + } + } + + return None; +} + +fn attrs_to_style(attrs: &[Attribute]) -> Style { + let mut style = Style::default(); + + for attr in attrs { + match attr.name.local.as_ref() { + "data-mx-bg-color" => { + if let Ok(rgb) = attr.value.as_ref().parse::() { + let color = Color::Rgb(rgb.r, rgb.g, rgb.b); + style = style.bg(color); + } + }, + "data-mx-color" | "color" => { + if let Ok(rgb) = attr.value.as_ref().parse::() { + let color = Color::Rgb(rgb.r, rgb.g, rgb.b); + style = style.fg(color); + } + }, + _ => continue, + } + } + + return style; +} + +fn h2t(hdl: &Handle) -> StyleTreeChildren { + let node = hdl.deref(); + + let tree = match &node.data { + NodeData::Document => *c2t(node.children.borrow().as_slice()), + NodeData::Text { contents } => StyleTreeNode::Text(contents.borrow().to_string()), + NodeData::Element { name, attrs, .. } => { + match name.local.as_ref() { + // Message that this one replies to. + "mx-reply" => StyleTreeNode::Reply(c2t(&node.children.borrow())), + + // Style change + "b" | "strong" => { + let c = c2t(&node.children.borrow()); + let s = Style::default().add_modifier(StyleModifier::BOLD); + + StyleTreeNode::Style(c, s) + }, + "font" => { + let c = c2t(&node.children.borrow()); + let s = attrs_to_style(&attrs.borrow()); + + StyleTreeNode::Style(c, s) + }, + "em" | "i" => { + let c = c2t(&node.children.borrow()); + let s = Style::default().add_modifier(StyleModifier::ITALIC); + + StyleTreeNode::Style(c, s) + }, + "span" => { + let c = c2t(&node.children.borrow()); + let s = attrs_to_style(&attrs.borrow()); + + StyleTreeNode::Style(c, s) + }, + "del" | "strike" => { + let c = c2t(&node.children.borrow()); + let s = Style::default().add_modifier(StyleModifier::CROSSED_OUT); + + StyleTreeNode::Style(c, s) + }, + "u" => { + let c = c2t(&node.children.borrow()); + let s = Style::default().add_modifier(StyleModifier::UNDERLINED); + + StyleTreeNode::Style(c, s) + }, + + // Lists + "ol" => StyleTreeNode::List(lic2t(&node.children.borrow()), ListStyle::Ordered), + "ul" => StyleTreeNode::List(lic2t(&node.children.borrow()), ListStyle::Unordered), + + // Headers + "h1" => StyleTreeNode::Header(c2t(&node.children.borrow()), 1), + "h2" => StyleTreeNode::Header(c2t(&node.children.borrow()), 2), + "h3" => StyleTreeNode::Header(c2t(&node.children.borrow()), 3), + "h4" => StyleTreeNode::Header(c2t(&node.children.borrow()), 4), + "h5" => StyleTreeNode::Header(c2t(&node.children.borrow()), 5), + "h6" => StyleTreeNode::Header(c2t(&node.children.borrow()), 6), + + // Table + "table" => { + let sections = table_sections(&node.children.borrow()); + let caption = node + .children + .borrow() + .iter() + .find_map(|hdl| get_node(hdl, "caption")) + .map(Box::new); + let table = Table { caption, sections }; + + StyleTreeNode::Table(table) + }, + + // Code blocks. + "code" => { + let c = c2t(&node.children.borrow()); + let l = attrs_to_language(&attrs.borrow()); + + StyleTreeNode::Code(c, l) + }, + + // Other text blocks. + "blockquote" => StyleTreeNode::Blockquote(c2t(&node.children.borrow())), + "div" | "p" => StyleTreeNode::Paragraph(c2t(&node.children.borrow())), + + // No children. + "hr" => StyleTreeNode::Ruler, + "br" => StyleTreeNode::Break, + + "img" => StyleTreeNode::Image(attrs_to_alt(&attrs.borrow())), + + // These don't render in any special way. + "a" | "details" | "html" | "pre" | "summary" | "sub" | "sup" => { + *c2t(&node.children.borrow()) + }, + + _ => return vec![], + } + }, + + // These don't render as anything. + NodeData::Doctype { .. } => return vec![], + NodeData::Comment { .. } => return vec![], + NodeData::ProcessingInstruction { .. } => return vec![], + }; + + vec![tree] +} + +fn dom_to_style_tree(dom: RcDom) -> StyleTree { + StyleTree { children: h2t(&dom.document) } +} + +pub fn parse_matrix_html(s: &str) -> StyleTree { + let dom = parse_fragment( + RcDom::default(), + ParseOpts::default(), + QualName::new(None, ns!(), local_name!("div")), + vec![], + ) + .one(StrTendril::from(s)); + + dom_to_style_tree(dom) +} + +#[cfg(test)] +pub mod tests { + use super::*; + use crate::util::space_span; + + #[test] + fn test_header() { + let bold = Style::default().add_modifier(StyleModifier::BOLD); + + let s = "

Header 1

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("# ", bold), + Span::styled("Header 1", bold), + space_span(10, Style::default()) + ])]); + + let s = "

Header 2

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("## ", bold), + Span::styled("Header 2", bold), + space_span(9, Style::default()) + ])]); + + let s = "

Header 3

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("### ", bold), + Span::styled("Header 3", bold), + space_span(8, Style::default()) + ])]); + + let s = "

Header 4

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("#### ", bold), + Span::styled("Header 4", bold), + space_span(7, Style::default()) + ])]); + + let s = "
Header 5
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("##### ", bold), + Span::styled("Header 5", bold), + space_span(6, Style::default()) + ])]); + + let s = "
Header 6
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("###### ", bold), + Span::styled("Header 6", bold), + space_span(5, Style::default()) + ])]); + } + + #[test] + fn test_style() { + let def = Style::default(); + let bold = def.add_modifier(StyleModifier::BOLD); + let italic = def.add_modifier(StyleModifier::ITALIC); + let strike = def.add_modifier(StyleModifier::CROSSED_OUT); + let underl = def.add_modifier(StyleModifier::UNDERLINED); + let red = def.fg(Color::Rgb(0xff, 0x00, 0x00)); + + let s = "Bold!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Bold!", bold), + space_span(15, def) + ])]); + + let s = "Bold!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Bold!", bold), + space_span(15, def) + ])]); + + let s = "Italic!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Italic!", italic), + space_span(13, def) + ])]); + + let s = "Italic!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Italic!", italic), + space_span(13, def) + ])]); + + let s = "Strikethrough!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Strikethrough!", strike), + space_span(6, def) + ])]); + + let s = "Strikethrough!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Strikethrough!", strike), + space_span(6, def) + ])]); + + let s = "Underline!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![ + Span::styled("Underline!", underl), + space_span(10, def) + ])]); + + let s = "Red!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![Span::styled("Red!", red), space_span(16, def)])]); + + let s = "Red!"; + let tree = parse_matrix_html(s); + let text = tree.to_text(20, Style::default(), false); + assert_eq!(text.lines, vec![Spans(vec![Span::styled("Red!", red), space_span(16, def)])]); + } + + #[test] + fn test_paragraph() { + let s = "

Hello world!

Content

Goodbye world!

"; + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false); + assert_eq!(text.lines.len(), 7); + assert_eq!(text.lines[0], Spans(vec![Span::raw("Hello worl")])); + assert_eq!(text.lines[1], Spans(vec![Span::raw("d!"), Span::raw(" ")])); + assert_eq!(text.lines[2], Spans(vec![Span::raw(" ")])); + assert_eq!(text.lines[3], Spans(vec![Span::raw("Content"), Span::raw(" ")])); + assert_eq!(text.lines[4], Spans(vec![Span::raw(" ")])); + assert_eq!(text.lines[5], Spans(vec![Span::raw("Goodbye wo")])); + assert_eq!(text.lines[6], Spans(vec![Span::raw("rld!"), Span::raw(" ")])); + } + + #[test] + fn test_blockquote() { + let s = "
Hello world!
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false); + assert_eq!(text.lines.len(), 2); + assert_eq!(text.lines[0], Spans(vec![Span::raw(" "), Span::raw("Hello ")])); + assert_eq!(text.lines[1], Spans(vec![Span::raw(" "), Span::raw("world!")])); + } + + #[test] + fn test_list_unordered() { + let s = "
  • List Item 1
  • List Item 2
  • List Item 3
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(8, Style::default(), false); + assert_eq!(text.lines.len(), 6); + assert_eq!(text.lines[0], Spans(vec![Span::raw("- "), Span::raw("List I")])); + assert_eq!(text.lines[1], Spans(vec![Span::raw(" "), Span::raw("tem 1"), Span::raw(" ")])); + assert_eq!(text.lines[2], Spans(vec![Span::raw("- "), Span::raw("List I")])); + assert_eq!(text.lines[3], Spans(vec![Span::raw(" "), Span::raw("tem 2"), Span::raw(" ")])); + assert_eq!(text.lines[4], Spans(vec![Span::raw("- "), Span::raw("List I")])); + assert_eq!(text.lines[5], Spans(vec![Span::raw(" "), Span::raw("tem 3"), Span::raw(" ")])); + } + + #[test] + fn test_list_ordered() { + let s = "
  1. List Item 1
  2. List Item 2
  3. List Item 3
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(9, Style::default(), false); + assert_eq!(text.lines.len(), 6); + assert_eq!(text.lines[0], Spans(vec![Span::raw("1. "), Span::raw("List I")])); + assert_eq!( + text.lines[1], + Spans(vec![Span::raw(" "), Span::raw("tem 1"), Span::raw(" ")]) + ); + assert_eq!(text.lines[2], Spans(vec![Span::raw("2. "), Span::raw("List I")])); + assert_eq!( + text.lines[3], + Spans(vec![Span::raw(" "), Span::raw("tem 2"), Span::raw(" ")]) + ); + assert_eq!(text.lines[4], Spans(vec![Span::raw("3. "), Span::raw("List I")])); + assert_eq!( + text.lines[5], + Spans(vec![Span::raw(" "), Span::raw("tem 3"), Span::raw(" ")]) + ); + } + + #[test] + fn test_table() { + let s = "\ + \ + + \ + \ + \ + \ + \ +
Column 1Column 2Column 3
abc
abc
abc
"; + let tree = parse_matrix_html(s); + let text = tree.to_text(15, Style::default(), false); + let bold = Style::default().add_modifier(StyleModifier::BOLD); + assert_eq!(text.lines.len(), 11); + + // Table header + assert_eq!(text.lines[0].0, vec![Span::raw("┌────┬────┬───┐")]); + assert_eq!(text.lines[1].0, vec![ + Span::raw("│"), + Span::styled("Colu", bold), + Span::raw("│"), + Span::styled("Colu", bold), + Span::raw("│"), + Span::styled("Col", bold), + Span::raw("│") + ]); + assert_eq!(text.lines[2].0, vec![ + Span::raw("│"), + Span::styled("mn 1", bold), + Span::raw("│"), + Span::styled("mn 2", bold), + Span::raw("│"), + Span::styled("umn", bold), + Span::raw("│") + ]); + assert_eq!(text.lines[3].0, vec![ + Span::raw("│"), + Span::raw(" "), + Span::raw("│"), + Span::raw(" "), + Span::raw("│"), + Span::styled(" 3", bold), + Span::styled(" ", bold), + Span::raw("│") + ]); + + // First row + assert_eq!(text.lines[4].0, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[5].0, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Second row + assert_eq!(text.lines[6].0, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[7].0, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Third row + assert_eq!(text.lines[8].0, vec![Span::raw("├────┼────┼───┤")]); + assert_eq!(text.lines[9].0, vec![ + Span::raw("│"), + Span::raw("a"), + Span::raw(" "), + Span::raw("│"), + Span::raw("b"), + Span::raw(" "), + Span::raw("│"), + Span::raw("c"), + Span::raw(" "), + Span::raw("│") + ]); + + // Bottom ruler + assert_eq!(text.lines[10].0, vec![Span::raw("└────┴────┴───┘")]); + } + + #[test] + fn test_matrix_reply() { + let s = "This was replied toThis is the reply"; + + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), false); + assert_eq!(text.lines.len(), 4); + assert_eq!(text.lines[0], Spans(vec![Span::raw("This was r")])); + assert_eq!(text.lines[1], Spans(vec![Span::raw("eplied to"), Span::raw(" ")])); + assert_eq!(text.lines[2], Spans(vec![Span::raw("This is th")])); + assert_eq!(text.lines[3], Spans(vec![Span::raw("e reply"), Span::raw(" ")])); + + let tree = parse_matrix_html(s); + let text = tree.to_text(10, Style::default(), true); + assert_eq!(text.lines.len(), 2); + assert_eq!(text.lines[0], Spans(vec![Span::raw("This is th")])); + assert_eq!(text.lines[1], Spans(vec![Span::raw("e reply"), Span::raw(" ")])); + } +} diff --git a/src/message.rs b/src/message/mod.rs similarity index 71% rename from src/message.rs rename to src/message/mod.rs index 3fd649a..82e665a 100644 --- a/src/message.rs +++ b/src/message/mod.rs @@ -4,19 +4,20 @@ use std::collections::hash_map::DefaultHasher; use std::collections::BTreeMap; use std::convert::TryFrom; use std::hash::{Hash, Hasher}; -use std::str::Lines; use chrono::{DateTime, NaiveDateTime, Utc}; -use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; use matrix_sdk::ruma::{ events::{ room::{ message::{ + FormattedBody, + MessageFormat, MessageType, OriginalRoomMessageEvent, RedactedRoomMessageEvent, + Relation, RoomMessageEvent, RoomMessageEventContent, }, @@ -33,6 +34,7 @@ use matrix_sdk::ruma::{ use modalkit::tui::{ style::{Modifier as StyleModifier, Style}, + symbols::line::THICK_VERTICAL, text::{Span, Spans, Text}, }; @@ -41,8 +43,13 @@ use modalkit::editing::{base::ViewportContext, cursor::Cursor}; use crate::{ base::{IambResult, RoomInfo}, config::ApplicationSettings, + message::html::{parse_matrix_html, StyleTree}, + util::{space_span, wrapped_text}, }; +mod html; +mod printer; + pub type MessageFetchResult = IambResult<(Option, Vec)>; pub type MessageKey = (MessageTimeStamp, OwnedEventId); pub type Messages = BTreeMap; @@ -62,65 +69,6 @@ const USER_GUTTER_EMPTY_SPAN: Span<'static> = Span { }, }; -struct WrappedLinesIterator<'a> { - iter: Lines<'a>, - curr: Option<&'a str>, - width: usize, -} - -impl<'a> WrappedLinesIterator<'a> { - fn new(input: &'a str, width: usize) -> Self { - WrappedLinesIterator { iter: input.lines(), curr: None, width } - } -} - -impl<'a> Iterator for WrappedLinesIterator<'a> { - type Item = (&'a str, usize); - - fn next(&mut self) -> Option { - if self.curr.is_none() { - self.curr = self.iter.next(); - } - - if let Some(s) = self.curr.take() { - let width = UnicodeWidthStr::width(s); - - if width <= self.width { - return Some((s, width)); - } else { - // Find where to split the line. - let mut width = 0; - let mut idx = 0; - - for (i, g) in UnicodeSegmentation::grapheme_indices(s, true) { - let gw = UnicodeWidthStr::width(g); - idx = i; - - if width + gw > self.width { - break; - } - - width += gw; - } - - self.curr = Some(&s[idx..]); - - return Some((&s[..idx], width)); - } - } else { - return None; - } - } -} - -fn wrap(input: &str, width: usize) -> WrappedLinesIterator<'_> { - WrappedLinesIterator::new(input, width) -} - -fn space(width: usize) -> String { - " ".repeat(width) -} - #[derive(thiserror::Error, Debug)] pub enum TimeStampIntError { #[error("Integer conversion error: {0}")] @@ -327,9 +275,9 @@ pub enum MessageEvent { } impl MessageEvent { - pub fn show(&self) -> Cow<'_, str> { + pub fn body(&self) -> Cow<'_, str> { match self { - MessageEvent::Original(ev) => show_room_content(&ev.content), + MessageEvent::Original(ev) => body_cow_content(&ev.content), MessageEvent::Redacted(ev) => { let reason = ev .unsigned @@ -344,7 +292,25 @@ impl MessageEvent { Cow::Borrowed("[Redacted]") } }, - MessageEvent::Local(content) => show_room_content(content), + MessageEvent::Local(content) => body_cow_content(content), + } + } + + pub fn html(&self) -> Option { + let content = match self { + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Redacted(_) => return None, + MessageEvent::Local(content) => content, + }; + + if let MessageType::Text(content) = &content.msgtype { + if let Some(FormattedBody { format: MessageFormat::Html, body }) = &content.formatted { + Some(parse_matrix_html(body.as_str())) + } else { + None + } + } else { + None } } @@ -360,18 +326,14 @@ impl MessageEvent { } } -fn show_room_content(content: &RoomMessageEventContent) -> Cow<'_, str> { +fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { let s = match &content.msgtype { - MessageType::Text(content) => content.body.as_ref(), + MessageType::Text(content) => content.body.as_str(), + MessageType::VerificationRequest(_) => "[Verification Request]", MessageType::Emote(content) => content.body.as_ref(), MessageType::Notice(content) => content.body.as_str(), MessageType::ServerNotice(content) => content.body.as_str(), - MessageType::VerificationRequest(_) => { - // XXX: implement - - return Cow::Owned("[verification request]".into()); - }, MessageType::Audio(content) => { return Cow::Owned(format!("[Attached Audio: {}]", content.body)); }, @@ -392,37 +354,93 @@ fn show_room_content(content: &RoomMessageEventContent) -> Cow<'_, str> { Cow::Borrowed(s) } -#[derive(Clone)] +enum MessageColumns<'a> { + Three(usize, Option>, Option>), + Two(usize, Option>), + One(usize, Option>), +} + +impl<'a> MessageColumns<'a> { + fn width(&self) -> usize { + match self { + MessageColumns::Three(fill, _, _) => *fill, + MessageColumns::Two(fill, _) => *fill, + MessageColumns::One(fill, _) => *fill, + } + } + + #[inline] + fn push_spans(&mut self, spans: Spans<'a>, style: Style, text: &mut Text<'a>) { + match self { + MessageColumns::Three(_, user, time) => { + let user = user.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); + let time = time.take().unwrap_or_else(|| Span::from("")); + + let mut line = vec![user]; + line.extend(spans.0); + line.push(time); + + text.lines.push(Spans(line)) + }, + MessageColumns::Two(_, opt) => { + let user = opt.take().unwrap_or(USER_GUTTER_EMPTY_SPAN); + let mut line = vec![user]; + line.extend(spans.0); + + text.lines.push(Spans(line)); + }, + MessageColumns::One(_, opt) => { + if let Some(user) = opt.take() { + text.lines.push(Spans(vec![user])); + } + + let leading = space_span(2, style); + let mut line = vec![leading]; + line.extend(spans.0); + + text.lines.push(Spans(line)); + }, + } + } + + fn push_text(&mut self, append: Text<'a>, style: Style, text: &mut Text<'a>) { + for line in append.lines.into_iter() { + self.push_spans(line, style, text); + } + } +} + pub struct Message { pub event: MessageEvent, pub sender: OwnedUserId, pub timestamp: MessageTimeStamp, pub downloaded: bool, + pub html: Option, } impl Message { pub fn new(event: MessageEvent, sender: OwnedUserId, timestamp: MessageTimeStamp) -> Self { - Message { event, sender, timestamp, downloaded: false } + let html = event.html(); + let downloaded = false; + + Message { event, sender, timestamp, downloaded, html } } - pub fn show( - &self, - prev: Option<&Message>, - selected: bool, - vwctx: &ViewportContext, - settings: &ApplicationSettings, - ) -> Text { - let width = vwctx.get_width(); - let mut msg = self.event.show(); + pub fn reply_to(&self) -> Option { + let content = match &self.event { + MessageEvent::Local(content) => content, + MessageEvent::Original(ev) => &ev.content, + MessageEvent::Redacted(_) => return None, + }; - if self.downloaded { - msg.to_mut().push_str(" \u{2705}"); + if let Some(Relation::Reply { in_reply_to }) = &content.relates_to { + Some(in_reply_to.event_id.clone()) + } else { + None } + } - let msg = msg.as_ref(); - - let mut lines = vec![]; - + fn get_render_style(&self, selected: bool) -> Style { let mut style = Style::default(); if selected { @@ -433,54 +451,109 @@ impl Message { style = style.add_modifier(StyleModifier::ITALIC); } + return style; + } + + fn get_render_format( + &self, + prev: Option<&Message>, + width: usize, + settings: &ApplicationSettings, + ) -> MessageColumns { if USER_GUTTER + TIME_GUTTER + MIN_MSG_LEN <= width { let lw = width - USER_GUTTER - TIME_GUTTER; + let user = self.show_sender(prev, true, settings); + let time = self.timestamp.show(); - for (i, (line, w)) in wrap(msg, lw).enumerate() { - let line = Span::styled(line.to_string(), style); - let trailing = Span::styled(space(lw.saturating_sub(w)), style); - - if i == 0 { - let user = self.show_sender(prev, true, settings); - - if let Some(time) = self.timestamp.show() { - lines.push(Spans(vec![user, line, trailing, time])) - } else { - lines.push(Spans(vec![user, line, trailing])) - } - } else { - let space = USER_GUTTER_EMPTY_SPAN; - - lines.push(Spans(vec![space, line, trailing])) - } - } + MessageColumns::Three(lw, user, time) } else if USER_GUTTER + MIN_MSG_LEN <= width { let lw = width - USER_GUTTER; + let user = self.show_sender(prev, true, settings); - for (i, (line, w)) in wrap(msg, lw).enumerate() { - let line = Span::styled(line.to_string(), style); - let trailing = Span::styled(space(lw.saturating_sub(w)), style); - - let prefix = if i == 0 { - self.show_sender(prev, true, settings) - } else { - USER_GUTTER_EMPTY_SPAN - }; - - lines.push(Spans(vec![prefix, line, trailing])) - } + MessageColumns::Two(lw, user) } else { - lines.push(Spans::from(self.show_sender(prev, false, settings))); + let lw = width.saturating_sub(2); + let user = self.show_sender(prev, false, settings); - for (line, _) in wrap(msg, width.saturating_sub(2)) { - let line = format!(" {}", line); - let line = Span::styled(line, style); + MessageColumns::One(lw, user) + } + } - lines.push(Spans(vec![line])) + pub fn show<'a>( + &'a self, + prev: Option<&Message>, + selected: bool, + vwctx: &ViewportContext, + info: &'a RoomInfo, + settings: &ApplicationSettings, + ) -> Text<'a> { + let width = vwctx.get_width(); + + let style = self.get_render_style(selected); + let mut fmt = self.get_render_format(prev, width, settings); + let mut text = Text { lines: vec![] }; + let width = fmt.width(); + + // Show the message that this one replied to, if any. + let reply = self.reply_to().and_then(|e| info.get_event(&e)); + + if let Some(r) = &reply { + let w = width.saturating_sub(2); + let mut replied = r.show_msg(w, style, true); + let mut sender = r.sender_span(settings); + let sender_width = UnicodeWidthStr::width(sender.content.as_ref()); + let trailing = w.saturating_sub(sender_width + 1); + + sender.style = sender.style.patch(style); + + fmt.push_spans( + Spans(vec![ + Span::styled(" ", style), + Span::styled(THICK_VERTICAL, style), + sender, + Span::styled(":", style), + space_span(trailing, style), + ]), + style, + &mut text, + ); + + for line in replied.lines.iter_mut() { + line.0.insert(0, Span::styled(THICK_VERTICAL, style)); + line.0.insert(0, Span::styled(" ", style)); } + + fmt.push_text(replied, style, &mut text); } - return Text { lines }; + // Now show the message contents, and the inlined reply if we couldn't find it above. + let msg = self.show_msg(width, style, reply.is_some()); + fmt.push_text(msg, style, &mut text); + + if text.lines.is_empty() { + // If there was nothing in the body, just show an empty message. + fmt.push_spans(space_span(width, style).into(), style, &mut text); + } + + return text; + } + + pub fn show_msg(&self, width: usize, style: Style, hide_reply: bool) -> Text { + if let Some(html) = &self.html { + html.to_text(width, style, hide_reply) + } else { + let mut msg = self.event.body(); + + if self.downloaded { + msg.to_mut().push_str(" \u{2705}"); + } + + wrapped_text(msg, width, style) + } + } + + fn sender_span(&self, settings: &ApplicationSettings) -> Span { + settings.get_user_span(self.sender.as_ref()) } fn show_sender( @@ -488,11 +561,11 @@ impl Message { prev: Option<&Message>, align_right: bool, settings: &ApplicationSettings, - ) -> Span { + ) -> Option { let user = if matches!(prev, Some(prev) if self.sender == prev.sender) { - USER_GUTTER_EMPTY_SPAN + return None; } else { - settings.get_user_span(self.sender.as_ref()) + self.sender_span(settings) }; let Span { content, style } = user; @@ -505,7 +578,7 @@ impl Message { format!("{: for Message { impl ToString for Message { fn to_string(&self) -> String { - self.event.show().into_owned() + self.event.body().into_owned() } } @@ -549,47 +622,6 @@ pub mod tests { use super::*; use crate::tests::*; - #[test] - fn test_wrapped_lines_ascii() { - let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye"; - - let mut iter = wrap(s, 100); - assert_eq!(iter.next(), Some(("hello world!", 12))); - assert_eq!(iter.next(), Some(("abcdefghijklmnopqrstuvwxyz", 26))); - assert_eq!(iter.next(), Some(("goodbye", 7))); - assert_eq!(iter.next(), None); - - let mut iter = wrap(s, 5); - assert_eq!(iter.next(), Some(("hello", 5))); - assert_eq!(iter.next(), Some((" worl", 5))); - assert_eq!(iter.next(), Some(("d!", 2))); - assert_eq!(iter.next(), Some(("abcde", 5))); - assert_eq!(iter.next(), Some(("fghij", 5))); - assert_eq!(iter.next(), Some(("klmno", 5))); - assert_eq!(iter.next(), Some(("pqrst", 5))); - assert_eq!(iter.next(), Some(("uvwxy", 5))); - assert_eq!(iter.next(), Some(("z", 1))); - assert_eq!(iter.next(), Some(("goodb", 5))); - assert_eq!(iter.next(), Some(("ye", 2))); - assert_eq!(iter.next(), None); - } - - #[test] - fn test_wrapped_lines_unicode() { - let s = "CHICKEN"; - - let mut iter = wrap(s, 14); - assert_eq!(iter.next(), Some((s, 14))); - assert_eq!(iter.next(), None); - - let mut iter = wrap(s, 5); - assert_eq!(iter.next(), Some(("CH", 4))); - assert_eq!(iter.next(), Some(("IC", 4))); - assert_eq!(iter.next(), Some(("KE", 4))); - assert_eq!(iter.next(), Some(("N", 2))); - assert_eq!(iter.next(), None); - } - #[test] fn test_mc_cmp() { let mc1 = MessageCursor::from(MSG1_KEY.clone()); diff --git a/src/message/printer.rs b/src/message/printer.rs new file mode 100644 index 0000000..eb3f811 --- /dev/null +++ b/src/message/printer.rs @@ -0,0 +1,157 @@ +use std::borrow::Cow; + +use modalkit::tui::layout::Alignment; +use modalkit::tui::style::Style; +use modalkit::tui::text::{Span, Spans, Text}; +use unicode_width::UnicodeWidthStr; + +use crate::util::{space_span, take_width}; + +pub struct TextPrinter<'a> { + text: Text<'a>, + width: usize, + base_style: Style, + hide_reply: bool, + + alignment: Alignment, + curr_spans: Vec>, + curr_width: usize, +} + +impl<'a> TextPrinter<'a> { + pub fn new(width: usize, base_style: Style, hide_reply: bool) -> Self { + TextPrinter { + text: Text::default(), + width, + base_style, + hide_reply, + + alignment: Alignment::Left, + curr_spans: vec![], + curr_width: 0, + } + } + + pub fn align(mut self, alignment: Alignment) -> Self { + self.alignment = alignment; + self + } + + pub fn hide_reply(&self) -> bool { + self.hide_reply + } + + pub fn width(&self) -> usize { + self.width + } + + pub fn sub(&self, indent: usize) -> Self { + TextPrinter { + text: Text::default(), + width: self.width.saturating_sub(indent), + base_style: self.base_style, + hide_reply: self.hide_reply, + + alignment: self.alignment, + curr_spans: vec![], + curr_width: 0, + } + } + + fn remaining(&self) -> usize { + self.width - self.curr_width + } + + pub fn commit(&mut self) { + if self.curr_width > 0 { + self.push_break(); + } + } + + fn push(&mut self) { + self.curr_width = 0; + self.text.lines.push(Spans(std::mem::take(&mut self.curr_spans))); + } + + pub fn push_break(&mut self) { + if self.curr_width == 0 && self.text.lines.is_empty() { + // Disallow leading breaks. + return; + } + + let remaining = self.remaining(); + + if remaining > 0 { + match self.alignment { + Alignment::Left => { + let tspan = space_span(remaining, self.base_style); + self.curr_spans.push(tspan); + }, + Alignment::Center => { + let trailing = remaining / 2; + let leading = remaining - trailing; + + let tspan = space_span(trailing, self.base_style); + let lspan = space_span(leading, self.base_style); + + self.curr_spans.push(tspan); + self.curr_spans.insert(0, lspan); + }, + Alignment::Right => { + let lspan = space_span(remaining, self.base_style); + self.curr_spans.insert(0, lspan); + }, + } + } + + self.push(); + } + + pub fn push_str(&mut self, s: T, style: Style) + where + T: Into>, + { + let style = self.base_style.patch(style); + let mut cow = s.into(); + + loop { + let sw = UnicodeWidthStr::width(cow.as_ref()); + + if self.curr_width + sw <= self.width { + // The text fits within the current line. + self.curr_spans.push(Span::styled(cow, style)); + self.curr_width += sw; + break; + } + + // Take a leading portion of the text that fits in the line. + let ((s0, w), s1) = take_width(cow, self.remaining()); + cow = s1; + + self.curr_spans.push(Span::styled(s0, style)); + self.curr_width += w; + + self.commit(); + } + + if self.curr_width == self.width { + // If the last bit fills the full line, start a new one. + self.push(); + } + } + + pub fn push_line(&mut self, spans: Spans<'a>) { + self.commit(); + self.text.lines.push(spans); + } + + pub fn push_text(&mut self, text: Text<'a>) { + self.commit(); + self.text.lines.extend(text.lines); + } + + pub fn finish(mut self) -> Text<'a> { + self.commit(); + self.text + } +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..e667412 --- /dev/null +++ b/src/util.rs @@ -0,0 +1,191 @@ +use std::borrow::Cow; + +use unicode_segmentation::UnicodeSegmentation; +use unicode_width::UnicodeWidthStr; + +use modalkit::tui::style::Style; +use modalkit::tui::text::{Span, Spans, Text}; + +pub fn split_cow(cow: Cow<'_, str>, idx: usize) -> (Cow<'_, str>, Cow<'_, str>) { + match cow { + Cow::Borrowed(s) => { + let s1 = Cow::Borrowed(&s[idx..]); + let s0 = Cow::Borrowed(&s[..idx]); + + (s0, s1) + }, + Cow::Owned(mut s) => { + let s1 = Cow::Owned(s.split_off(idx)); + let s0 = Cow::Owned(s); + + (s0, s1) + }, + } +} + +pub fn take_width(s: Cow<'_, str>, width: usize) -> ((Cow<'_, str>, usize), Cow<'_, str>) { + // Find where to split the line. + let mut idx = 0; + let mut w = 0; + + for (i, g) in UnicodeSegmentation::grapheme_indices(s.as_ref(), true) { + let gw = UnicodeWidthStr::width(g); + idx = i; + + if w + gw > width { + break; + } + + w += gw; + } + + let (s0, s1) = split_cow(s, idx); + + ((s0, w), s1) +} + +pub struct WrappedLinesIterator<'a> { + iter: std::vec::IntoIter>, + curr: Option>, + width: usize, +} + +impl<'a> WrappedLinesIterator<'a> { + fn new(input: T, width: usize) -> Self + where + T: Into>, + { + let width = width.max(2); + + let cows: Vec> = match input.into() { + Cow::Borrowed(s) => s.lines().map(Cow::Borrowed).collect(), + Cow::Owned(s) => s.lines().map(ToOwned::to_owned).map(Cow::Owned).collect(), + }; + + WrappedLinesIterator { iter: cows.into_iter(), curr: None, width } + } +} + +impl<'a> Iterator for WrappedLinesIterator<'a> { + type Item = (Cow<'a, str>, usize); + + fn next(&mut self) -> Option { + if self.curr.is_none() { + self.curr = self.iter.next(); + } + + if let Some(s) = self.curr.take() { + let width = UnicodeWidthStr::width(s.as_ref()); + + if width <= self.width { + return Some((s, width)); + } else { + let (prefix, s1) = take_width(s, self.width); + self.curr = Some(s1); + return Some(prefix); + } + } else { + return None; + } + } +} + +pub fn wrap<'a, T>(input: T, width: usize) -> WrappedLinesIterator<'a> +where + T: Into>, +{ + WrappedLinesIterator::new(input, width) +} + +pub fn wrapped_text<'a, T>(s: T, width: usize, style: Style) -> Text<'a> +where + T: Into>, +{ + let mut text = Text::default(); + + for (line, w) in wrap(s, width) { + let space = space_span(width.saturating_sub(w), style); + let spans = Spans(vec![Span::styled(line, style), space]); + + text.lines.push(spans); + } + + return text; +} + +pub fn space(width: usize) -> String { + " ".repeat(width) +} + +pub fn space_span(width: usize, style: Style) -> Span<'static> { + Span::styled(space(width), style) +} + +pub fn space_text(width: usize, style: Style) -> Text<'static> { + space_span(width, style).into() +} + +pub fn join_cell_text<'a>(texts: Vec<(Text<'a>, usize)>, join: Span<'a>) -> Text<'a> { + let height = texts.iter().map(|t| t.0.height()).max().unwrap_or(0); + let mut text = Text { lines: vec![Spans(vec![join.clone()]); height] }; + + for (mut t, w) in texts.into_iter() { + for i in 0..height { + if let Some(spans) = t.lines.get_mut(i) { + text.lines[i].0.append(&mut spans.0); + } else { + text.lines[i].0.push(space_span(w, Style::default())); + } + + text.lines[i].0.push(join.clone()); + } + } + + text +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn test_wrapped_lines_ascii() { + let s = "hello world!\nabcdefghijklmnopqrstuvwxyz\ngoodbye"; + + let mut iter = wrap(s, 100); + assert_eq!(iter.next(), Some((Cow::Borrowed("hello world!"), 12))); + assert_eq!(iter.next(), Some((Cow::Borrowed("abcdefghijklmnopqrstuvwxyz"), 26))); + assert_eq!(iter.next(), Some((Cow::Borrowed("goodbye"), 7))); + assert_eq!(iter.next(), None); + + let mut iter = wrap(s, 5); + assert_eq!(iter.next(), Some((Cow::Borrowed("hello"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed(" worl"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("d!"), 2))); + assert_eq!(iter.next(), Some((Cow::Borrowed("abcde"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("fghij"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("klmno"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("pqrst"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("uvwxy"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("z"), 1))); + assert_eq!(iter.next(), Some((Cow::Borrowed("goodb"), 5))); + assert_eq!(iter.next(), Some((Cow::Borrowed("ye"), 2))); + assert_eq!(iter.next(), None); + } + + #[test] + fn test_wrapped_lines_unicode() { + let s = "CHICKEN"; + + let mut iter = wrap(s, 14); + assert_eq!(iter.next(), Some((Cow::Borrowed(s), 14))); + assert_eq!(iter.next(), None); + + let mut iter = wrap(s, 5); + assert_eq!(iter.next(), Some((Cow::Borrowed("CH"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("IC"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("KE"), 4))); + assert_eq!(iter.next(), Some((Cow::Borrowed("N"), 2))); + assert_eq!(iter.next(), None); + } +} diff --git a/src/windows/room/chat.rs b/src/windows/room/chat.rs index 6e557c4..9ce1205 100644 --- a/src/windows/room/chat.rs +++ b/src/windows/room/chat.rs @@ -324,7 +324,7 @@ impl ChatState { return Ok(None); } - let msg = TextMessageEventContent::plain(msg); + let msg = TextMessageEventContent::markdown(msg); let msg = MessageType::Text(msg); let mut msg = RoomMessageEventContent::new(msg); diff --git a/src/windows/room/scrollback.rs b/src/windows/room/scrollback.rs index 5030715..d6436f7 100644 --- a/src/windows/room/scrollback.rs +++ b/src/windows/room/scrollback.rs @@ -214,7 +214,7 @@ impl ScrollbackState { for (key, item) in info.messages.range(..=&idx).rev() { let sel = selidx == key; - let len = item.show(None, sel, &self.viewctx, settings).lines.len(); + let len = item.show(None, sel, &self.viewctx, info, settings).lines.len(); if key == &idx { lines += len / 2; @@ -236,7 +236,7 @@ impl ScrollbackState { for (key, item) in info.messages.range(..=&idx).rev() { let sel = key == selidx; - let len = item.show(None, sel, &self.viewctx, settings).lines.len(); + let len = item.show(None, sel, &self.viewctx, info, settings).lines.len(); lines += len; @@ -276,7 +276,7 @@ impl ScrollbackState { break; } - lines += item.show(None, false, &self.viewctx, settings).height().max(1); + lines += item.show(None, false, &self.viewctx, info, settings).height().max(1); if lines >= self.viewctx.get_height() { // We've reached the end of the viewport; move cursor into it. @@ -431,7 +431,7 @@ impl ScrollbackState { continue; } - if needle.is_match(msg.event.show().as_ref()) { + if needle.is_match(msg.event.body().as_ref()) { mc = MessageCursor::from(key.clone()).into(); count -= 1; } @@ -455,7 +455,7 @@ impl ScrollbackState { break; } - if needle.is_match(msg.event.show().as_ref()) { + if needle.is_match(msg.event.body().as_ref()) { mc = MessageCursor::from(key.clone()).into(); count -= 1; } @@ -704,7 +704,7 @@ impl EditorActions for ScrollbackState { let mut yanked = EditRope::from(""); for (_, msg) in self.messages(range, info) { - yanked += EditRope::from(msg.event.show().into_owned()); + yanked += EditRope::from(msg.event.body()); yanked += EditRope::from('\n'); } @@ -1009,7 +1009,7 @@ impl ScrollActions for ScrollbackState { for (key, item) in info.messages.range(..=&corner_key).rev() { let sel = key == cursor_key; - let txt = item.show(None, sel, &self.viewctx, settings); + let txt = item.show(None, sel, &self.viewctx, info, settings); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -1035,7 +1035,7 @@ impl ScrollActions for ScrollbackState { MoveDir2D::Down => { for (key, item) in info.messages.range(&corner_key..) { let sel = key == cursor_key; - let txt = item.show(None, sel, &self.viewctx, settings); + let txt = item.show(None, sel, &self.viewctx, info, settings); let len = txt.height().max(1); let max = len.saturating_sub(1); @@ -1218,7 +1218,7 @@ impl<'a> StatefulWidget for Scrollback<'a> { for (key, item) in info.messages.range(&corner_key..) { let sel = key == cursor_key; - let txt = item.show(prev, foc && sel, &state.viewctx, settings); + let txt = item.show(prev, foc && sel, &state.viewctx, info, settings); prev = Some(item);