├── .editorconfig ├── .github └── workflows │ ├── luarocks.yml │ └── release-please.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lua └── neorg │ └── modules │ └── external │ └── conceal-wrap │ └── module.lua └── neorg-conceal-wrap-scm-1.rockspec /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | indent_style = space 3 | indent_size = 4 4 | -------------------------------------------------------------------------------- /.github/workflows/luarocks.yml: -------------------------------------------------------------------------------- 1 | name: Push to Luarocks 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | release: 8 | types: 9 | - created 10 | pull_request: # Test luarocks install without publishing on PR 11 | workflow_dispatch: 12 | 13 | jobs: 14 | luarocks-upload: 15 | runs-on: ubuntu-22.04 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 # Required to count the commits 20 | - name: Get Version 21 | run: echo "LUAROCKS_VERSION=$(git describe --abbrev=0 --tags)" >> $GITHUB_ENV 22 | - name: LuaRocks Upload 23 | uses: nvim-neorocks/luarocks-tag-release@v6 24 | env: 25 | LUAROCKS_API_KEY: ${{ secrets.LUAROCKS_API_KEY }} 26 | with: 27 | version: ${{ env.LUAROCKS_VERSION }} 28 | dependencies: | 29 | neorg 30 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | --- 2 | permissions: 3 | contents: write 4 | pull-requests: write 5 | 6 | name: Release Please 7 | 8 | on: 9 | workflow_dispatch: 10 | push: 11 | branches: 12 | - main 13 | 14 | jobs: 15 | release: 16 | name: release 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: google-github-actions/release-please-action@v4 20 | with: 21 | release-type: simple 22 | token: ${{ secrets.PAT }} 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Compiled Lua sources 2 | luac.out 3 | 4 | # luarocks build files 5 | *.src.rock 6 | *.zip 7 | *.tar.gz 8 | 9 | # Object files 10 | *.o 11 | *.os 12 | *.ko 13 | *.obj 14 | *.elf 15 | 16 | # Precompiled Headers 17 | *.gch 18 | *.pch 19 | 20 | # Libraries 21 | *.lib 22 | *.a 23 | *.la 24 | *.lo 25 | *.def 26 | *.exp 27 | 28 | # Shared objects (inc. Windows DLLs) 29 | *.dll 30 | *.so 31 | *.so.* 32 | *.dylib 33 | 34 | # Executables 35 | *.exe 36 | *.out 37 | *.app 38 | *.i*86 39 | *.x86_64 40 | *.hex 41 | 42 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.0.1](https://github.com/benlubas/neorg-conceal-wrap/compare/v1.0.0...v1.0.1) (2024-09-07) 4 | 5 | 6 | ### Bug Fixes 7 | 8 | * don't break at `:` ([#6](https://github.com/benlubas/neorg-conceal-wrap/issues/6)) ([5138c85](https://github.com/benlubas/neorg-conceal-wrap/commit/5138c85a19e4135dc0aae22893b6d756229adc4e)) 9 | 10 | ## 1.0.0 (2024-06-30) 11 | 12 | 13 | ### Features 14 | 15 | * don't wreck headings/lists ([#4](https://github.com/benlubas/neorg-conceal-wrap/issues/4)) ([1199cbe](https://github.com/benlubas/neorg-conceal-wrap/commit/1199cbe30b8ca01d15e713306c3d2523e66fad8c)) 16 | 17 | 18 | ### Bug Fixes 19 | 20 | * handle tw=0 ([#3](https://github.com/benlubas/neorg-conceal-wrap/issues/3)) ([a50139a](https://github.com/benlubas/neorg-conceal-wrap/commit/a50139ac601c1d35bb4463e897bfaf41aa0535b7)) 21 | * module name ([fab5789](https://github.com/benlubas/neorg-conceal-wrap/commit/fab5789687857c979b687edba96f308c173d0d2d)) 22 | * module name ([3007b02](https://github.com/benlubas/neorg-conceal-wrap/commit/3007b0286c988f3f3e6570872fa847ed43f06507)) 23 | * off by ones and performance bump ([#2](https://github.com/benlubas/neorg-conceal-wrap/issues/2)) ([129a98f](https://github.com/benlubas/neorg-conceal-wrap/commit/129a98f083faa16cec934c308497adef4138ac19)) 24 | * various off by one issues ([#1](https://github.com/benlubas/neorg-conceal-wrap/issues/1)) ([b34c5e4](https://github.com/benlubas/neorg-conceal-wrap/commit/b34c5e4330000ee5f850e1a8b2446fc8617e57a9)) 25 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ben Lubas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # neorg-conceal-wrap 2 | 3 | Wrap lines based on their concealed width instead of their unconcealed width. 4 | 5 | ![image](https://github.com/benlubas/neorg-conceal-wrap/assets/56943754/34900a49-7f4b-45e5-ba35-2fbb980c8e88) 6 | 7 | --- 8 | 9 | ## Install 10 | 11 | Install this plugin and load it by adding this to your neorg config: 12 | 13 | ```lua 14 | ["external.conceal-wrap"] = {}, 15 | ``` 16 | 17 | There is no configuration. `:h textwidth` is used as the target width of a line. 18 | 19 | ## Usage 20 | 21 | This plugin overwrites the `formatexpr` for `.norg` buffers, so formatting is applied with the `gq` 22 | mapping. see `:h 'formatexpr'` and `:h gq` for details. TL;DR: use `gq` to format 23 | the text object. 24 | 25 | Formatting in insert mode falls back to normal nvim formatting. This is for a few reasons I guess, 26 | the main one being that I don't care to implement it right now. But also, while typing syntax is 27 | often broken and this would result in needing to reformat sometimes anyway. 28 | -------------------------------------------------------------------------------- /lua/neorg/modules/external/conceal-wrap/module.lua: -------------------------------------------------------------------------------- 1 | --[[ 2 | file: Conceal-Wrap 3 | title: Hard wrap text based on it's concealed width 4 | --- 5 | 6 | Features: 7 | - Avoid joining text into headers 8 | - Avoid joining list items 9 | 10 | --]] 11 | 12 | local neorg = require("neorg.core") 13 | local modules, log = neorg.modules, neorg.log 14 | 15 | local module = modules.create("external.conceal-wrap") 16 | 17 | module.setup = function() 18 | return { 19 | success = true, 20 | } 21 | end 22 | 23 | module.load = function() 24 | local ns = vim.api.nvim_create_augroup("neorg-conceal-wrap", { clear = true }) 25 | 26 | module.private.break_at = vim.iter(vim.split(vim.o.breakat, "")) 27 | :filter(function(x) 28 | return not vim.list_contains(module.config.private.no_break_at, x) 29 | end) 30 | :totable() 31 | 32 | vim.api.nvim_create_autocmd("BufEnter", { 33 | desc = "Set the format expression on norg buffers", 34 | pattern = "*.norg", 35 | group = ns, 36 | callback = function(ev) 37 | -- set the format expression for the buffer. 38 | vim.api.nvim_set_option_value( 39 | "formatexpr", 40 | "v:lua.require'neorg.modules.external.conceal-wrap.module'.public.format()", 41 | { buf = ev.buf } 42 | ) 43 | end, 44 | }) 45 | end 46 | 47 | module.config.public = {} 48 | 49 | module.config.private = {} 50 | 51 | ---Chars that we remove from break-at when wrapping lines. Break-at is global, and we don't want to 52 | ---mess with it. We will respect it until it starts to break syntax... Hmm. these are all valid in 53 | ---between words though, so maybe we could check that? Like this/that is fine, and doesn't start an 54 | ---italic section. so it would be okay to break there. Then we also have to consider when they touch 55 | ---against new lines though, that's annoying too. I think I will just remove them from breakat for 56 | ---now then. 57 | module.config.private.no_break_at = { "/", ",", "!", "-", "*", ":" } 58 | 59 | ---join lines defined by the 0 index start and end into a single line. Lines are separated by single 60 | ---spaces. 61 | ---@param buf number 62 | ---@param start number 0 based start 63 | ---@param _end number 0 based exclusive end 64 | module.private.join_lines = function(buf, start, _end) 65 | local og_lines = vim.api.nvim_buf_get_lines(buf, start, _end, false) 66 | local joined = vim.iter(og_lines) 67 | :map(function(x) 68 | x = x:gsub("^%s+", "") 69 | x = x:gsub("%s+$", "") 70 | return x 71 | end) 72 | :join(" ") 73 | vim.api.nvim_buf_set_lines(buf, start, _end, false, { joined }) 74 | end 75 | 76 | ---Function to be used as `:h 'formatexpr'` which will hard wrap text in such a way that lines will 77 | ---be `textwidth` long when conceal is active 78 | ---@return integer 79 | module.public.format = function() 80 | if vim.api.nvim_get_mode().mode == "i" then 81 | -- Returning 1 will tell nvim to fallback to the normal format method (which is capable of 82 | -- handling insert mode much better than we can currently) 83 | -- TODO: I think the issue might be that we remove blank spaces from the end when in insert 84 | -- mode, which causes problems 85 | return 1 86 | end 87 | local buf = vim.api.nvim_get_current_buf() 88 | local current_row = vim.v.lnum - 1 89 | 90 | -- group the lines by header/list items, etc.. 91 | local groups = {} 92 | local next_group = {} 93 | local lines = vim.api.nvim_buf_get_lines(buf, current_row, current_row + vim.v.count, true) 94 | for i, line in ipairs(lines) do 95 | local ln = i + current_row - 1 96 | if line:match("^%s*%*+%s") then 97 | -- this is a header, it gets its own group 98 | table.insert(groups, next_group) 99 | next_group = {} 100 | table.insert(groups, { ln }) 101 | elseif line:match("^%s*%-+%s") then 102 | -- this is a list item, don't join the group above, but allow lines below to join 103 | table.insert(groups, next_group) 104 | next_group = { ln } 105 | elseif line:match("^%s*$") then 106 | -- this is a blank line, break the group 107 | table.insert(groups, next_group) 108 | next_group = {} 109 | else 110 | table.insert(next_group, ln) 111 | end 112 | end 113 | table.insert(groups, next_group) 114 | 115 | local offset = 0 116 | for _, group in ipairs(groups) do 117 | if #group == 0 then 118 | goto continue 119 | end 120 | module.private.join_lines(buf, group[1] + offset, group[#group] + 1 + offset) 121 | local new_line_len = module.private.format_joined_line(buf, group[1] + offset) 122 | offset = offset + (new_line_len - #group) 123 | ::continue:: 124 | end 125 | 126 | -- module.private.join_lines(buf, current_row, current_row + vim.v.count) 127 | -- module.private.format_joined_line(buf, current_row) 128 | 129 | return 0 130 | end 131 | 132 | ---Format a single line that's been joined 133 | ---@param buf number 134 | ---@param line_idx number 0 based line index 135 | ---@return number lines the number of lines the formatted text takes up 136 | module.private.format_joined_line = function(buf, line_idx) 137 | local ok, err = pcall(function() 138 | local line = vim.api.nvim_buf_get_lines(buf, line_idx, line_idx + 1, false)[1] 139 | local new_lines = {} 140 | 141 | ---kinda like a byte index, It's just how far we are in the string of text. 142 | local col_index = 0 143 | local width = vim.bo.textwidth 144 | if width == 0 then 145 | width = 80 -- this is the value the built-in formatter defaults to when tw=0 146 | end 147 | 148 | -- account for breakindent 149 | vim.v.lnum = line_idx + 1 150 | local indent = vim.fn.eval(vim.bo.indentexpr) 151 | 152 | local left_offset = indent 153 | 154 | width = math.max(width - left_offset, 5) -- arbitrary 5 char limit 155 | while #line > 0 do 156 | local visible_width, next_cutoff_index = 157 | module.private.visible_text_width(buf, line_idx, col_index, col_index + #line, width) 158 | 159 | if visible_width <= width then 160 | table.insert(new_lines, line) 161 | break 162 | end 163 | -- definitely need this + 1 right now 164 | -- what is this + 1 for though? 165 | local chunk = line:sub(0, next_cutoff_index - col_index + 1) 166 | 167 | local i = #chunk 168 | while i > 0 do 169 | if vim.list_contains(module.private.break_at, chunk:sub(i, i)) then 170 | break 171 | end 172 | i = i - 1 173 | end 174 | if i == 0 then 175 | -- we didn't find a space, so we just break the line at the width 176 | i = next_cutoff_index 177 | end 178 | table.insert(new_lines, line:sub(0, i)) 179 | col_index = col_index + i 180 | line = line:sub(i + 1) 181 | end 182 | 183 | new_lines = vim.iter(new_lines) 184 | :map(function(l) 185 | l = l:gsub("^%s*", (" "):rep(indent)) 186 | l = l:gsub("%s+$", "") 187 | return l 188 | end) 189 | :totable() 190 | -- Now we have new lines, have to write them to the buffer 191 | vim.api.nvim_buf_set_lines(buf, line_idx, line_idx + 1, false, new_lines) 192 | return #new_lines 193 | end) 194 | if not ok then 195 | log.error(err) 196 | end 197 | return err 198 | end 199 | 200 | ---Compute the "visible" width of the given range, that is the width of the line when conceal is active. 201 | ---If the visible width is larger than target, return the position of the last visible character before target. 202 | ---@param buf number 203 | ---@param line_idx number 0 based line number 204 | ---@param start_col number 0 based 205 | ---@param end_col number 0 based, exclusive 206 | ---@param target number 207 | ---@return number width, number next_index 208 | module.private.visible_text_width = function(buf, line_idx, start_col, end_col, target) 209 | -- offset x by inline virtual text 210 | local width = 0 211 | -- track positions that are concealed by extmarks 212 | local extmark_concealed = {} 213 | local same_line_extmarks = vim.api.nvim_buf_get_extmarks( 214 | buf, 215 | -1, 216 | { line_idx, start_col }, 217 | { line_idx, end_col }, 218 | { details = true } 219 | ) 220 | for _, extmark in ipairs(same_line_extmarks) do 221 | local details = extmark[4] 222 | -- we don't care if conceal is on or off. Always wrap for when it's on 223 | if details.conceal and details.end_col then 224 | -- remove width b/c this is removing space 225 | for i = extmark[3], details.end_col do 226 | extmark_concealed[i] = true 227 | end 228 | width = width - (details.end_col - extmark[3]) 229 | end 230 | end 231 | 232 | local best_target = start_col 233 | -- col indexing is 0 based 234 | for c = start_col, end_col - 1 do 235 | local visible = true 236 | if extmark_concealed[c + 1] then 237 | visible = false 238 | else 239 | local res = vim.treesitter.get_captures_at_pos(buf, line_idx, c) 240 | for _, hl in ipairs(res) do 241 | if hl.capture == "conceal" then 242 | visible = false 243 | break 244 | end 245 | end 246 | end 247 | if visible then 248 | width = width + 1 249 | 250 | -- target + 1 b/c if the lines ends where "d" in "word " is at `target` then we should break at the " " 251 | if width <= target + 1 then 252 | best_target = c 253 | end 254 | end 255 | end 256 | return width, best_target 257 | end 258 | 259 | return module 260 | -------------------------------------------------------------------------------- /neorg-conceal-wrap-scm-1.rockspec: -------------------------------------------------------------------------------- 1 | local MODREV, SPECREV = "scm", "-1" 2 | rockspec_format = "3.0" 3 | package = "neorg-conceal-wrap" 4 | version = MODREV .. SPECREV 5 | 6 | description = { 7 | summary = "Neorg module to hard wrap text based on it's concealed width", 8 | labels = { "neovim" }, 9 | homepage = "https://github.com/benluas/neorg-conceal-wrap", 10 | license = "MIT", 11 | } 12 | 13 | source = { 14 | url = "http://github.com/benlubas/neorg-conceal-wrap/archive/v" .. MODREV .. ".zip", 15 | } 16 | 17 | if MODREV == "scm" then 18 | source = { 19 | url = "git://github.com/benlubas/neorg-conceal-wrap", 20 | } 21 | end 22 | 23 | dependencies = { 24 | "neorg ~> 8", 25 | } 26 | --------------------------------------------------------------------------------