/ lua / cellmode / runtime / controller.lua
controller.lua
  1  local session_store = require("cellmode.runtime.session_store")
  2  local cell_layout = require("cellmode.view.cell_layout")
  3  local overlay = require("cellmode.view.overlay")
  4  local sticky_header = require("cellmode.view.sticky_header")
  5  local csv_parser = require("cellmode.codec.csv_parser")
  6  local keymaps = require("cellmode.runtime.keymaps")
  7  
  8  local M = {}
  9  
 10  local function detect_format(bufnr, override)
 11    if override == "csv" or override == "tsv" then
 12      return override
 13    end
 14    local ft = vim.bo[bufnr].filetype
 15    if ft == "csv" or ft == "tsv" then
 16      return ft
 17    end
 18    local path = vim.api.nvim_buf_get_name(bufnr)
 19    local ext = path:match("^.+%.([^.]+)$")
 20    if ext == "tsv" then
 21      return "tsv"
 22    end
 23    return "csv"
 24  end
 25  
 26  M.detect_format = detect_format
 27  
 28  local function apply_window_options_for_buffer(bufnr)
 29    for _, winid in ipairs(vim.fn.win_findbuf(bufnr)) do
 30      overlay.apply_window_options(winid)
 31    end
 32  end
 33  
 34  function M.open(bufnr, opts)
 35    opts = opts or {}
 36    if not vim.api.nvim_buf_is_valid(bufnr) then
 37      return false, "invalid buffer"
 38    end
 39    local format = detect_format(bufnr, opts.format)
 40    cell_layout.build(bufnr, format)
 41    session_store.open(bufnr, {
 42      format = format,
 43      overlay_visible = true,
 44    })
 45    apply_window_options_for_buffer(bufnr)
 46    overlay.redraw(bufnr)
 47    sticky_header.refresh_buffer(bufnr)
 48    keymaps.attach(bufnr)
 49    return true
 50  end
 51  
 52  function M.close(bufnr)
 53    keymaps.detach(bufnr)
 54    sticky_header.disable_for_buffer(bufnr)
 55    session_store.close(bufnr)
 56    cell_layout.clear(bufnr)
 57    overlay.forget(bufnr)
 58  end
 59  
 60  function M.on_buffer_changed(bufnr, change)
 61    local session = session_store.get(bufnr)
 62    if not session then
 63      return
 64    end
 65    if session.updating_buffer then
 66      return
 67    end
 68    local result = cell_layout.apply_edit(bufnr, change)
 69    if not result then
 70      return
 71    end
 72    if result.full or result.widths_changed then
 73      overlay.redraw(bufnr)
 74    else
 75      overlay.redraw_range(bufnr, result.first_record, result.last_record, result.widths_changed)
 76    end
 77    sticky_header.refresh_buffer(bufnr)
 78  end
 79  
 80  function M.toggle_overlay(bufnr)
 81    local session = session_store.get(bufnr)
 82    if not session then
 83      return false, "session not found"
 84    end
 85    local visible = session.overlay_visible == false
 86    session_store.set_overlay_visible(bufnr, visible)
 87    overlay.set_visible(bufnr, visible)
 88    if visible then
 89      sticky_header.refresh_buffer(bufnr)
 90    else
 91      sticky_header.disable_for_buffer(bufnr)
 92    end
 93    return true
 94  end
 95  
 96  local function delim_for(format)
 97    return csv_parser.delimiter_for_format(format)
 98  end
 99  
100  local function encode_cell(value, delim)
101    local text = tostring(value or "")
102    local needs = text:find('"', 1, true)
103      or text:find(delim, 1, true)
104      or text:find("\n", 1, true)
105      or text:find("\r", 1, true)
106    if not needs then
107      return text
108    end
109    return '"' .. text:gsub('"', '""') .. '"'
110  end
111  
112  M.encode_cell = encode_cell
113  
114  local function set_lines_unmanaged(bufnr, start_row0, end_row0, lines)
115    session_store.set_updating_buffer(bufnr, true)
116    vim.api.nvim_buf_set_lines(bufnr, start_row0, end_row0, false, lines)
117    session_store.set_updating_buffer(bufnr, false)
118  end
119  
120  local function set_text_unmanaged(bufnr, sr, sc, er, ec, lines)
121    session_store.set_updating_buffer(bufnr, true)
122    vim.api.nvim_buf_set_text(bufnr, sr, sc, er, ec, lines)
123    session_store.set_updating_buffer(bufnr, false)
124  end
125  
126  function M.set_cell(bufnr, irec, icol, value)
127    local session = session_store.get(bufnr)
128    if not session then
129      return false, "session not found"
130    end
131    local layout = cell_layout.get(bufnr)
132    if not layout then
133      return false, "layout not built"
134    end
135    local record = layout.records[irec]
136    if not record then
137      return false, "record out of range"
138    end
139    local field = record.fields[icol]
140    local delim = delim_for(session.format)
141    local encoded = encode_cell(value, delim)
142  
143    if field then
144      local sr = field.byte_start_row - 1
145      local sc = field.byte_start_col - 1
146      local er = field.byte_end_row - 1
147      local ec = field.byte_end_col
148      local replacement = vim.split(encoded, "\n", { plain = true })
149      set_text_unmanaged(bufnr, sr, sc, er, ec, replacement)
150    else
151      local last_field = record.fields[#record.fields]
152      if not last_field then
153        return false, "record has no fields"
154      end
155      local missing = icol - #record.fields
156      local insert = ""
157      for _ = 1, missing - 1 do
158        insert = insert .. delim
159      end
160      insert = insert .. delim .. encoded
161      local sr = last_field.byte_end_row - 1
162      local sc = last_field.byte_end_col
163      set_text_unmanaged(bufnr, sr, sc, sr, sc, vim.split(insert, "\n", { plain = true }))
164    end
165  
166    cell_layout.build(bufnr, session.format)
167    overlay.redraw(bufnr)
168    return true
169  end
170  
171  function M.insert_row(bufnr, irec, values)
172    local session = session_store.get(bufnr)
173    if not session then
174      return false, "session not found"
175    end
176    local layout = cell_layout.get(bufnr)
177    if not layout then
178      return false, "layout not built"
179    end
180    local delim = delim_for(session.format)
181    local cells = {}
182    for i = 1, #(values or {}) do
183      cells[i] = encode_cell(values[i], delim)
184    end
185    local row_text = table.concat(cells, delim)
186    local row_lines = vim.split(row_text, "\n", { plain = true })
187  
188    local insert_at0
189    local total_records = #layout.records
190    if irec <= 0 then
191      insert_at0 = 0
192    elseif irec > total_records then
193      insert_at0 = layout.line_count
194    else
195      insert_at0 = layout.records[irec].buf_row_start - 1
196    end
197    set_lines_unmanaged(bufnr, insert_at0, insert_at0, row_lines)
198    cell_layout.build(bufnr, session.format)
199    overlay.redraw(bufnr)
200    return true
201  end
202  
203  function M.delete_row(bufnr, irec)
204    local session = session_store.get(bufnr)
205    if not session then
206      return false, "session not found"
207    end
208    local layout = cell_layout.get(bufnr)
209    if not layout then
210      return false, "layout not built"
211    end
212    local record = layout.records[irec]
213    if not record then
214      return false, "record out of range"
215    end
216    set_lines_unmanaged(bufnr, record.buf_row_start - 1, record.buf_row_end, {})
217    cell_layout.build(bufnr, session.format)
218    overlay.redraw(bufnr)
219    return true
220  end
221  
222  return M