From 1075366382f679629cab39fbc6b7b84606d8bfb5 Mon Sep 17 00:00:00 2001 From: dlond Date: Mon, 13 Oct 2025 17:19:19 +1300 Subject: [PATCH 1/2] feat: Enhanced Jupyter notebook support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improved Molten configuration with better cell handling - Added Jupytext for .ipynb ↔ .py conversion - Added image.nvim for inline plot display - Smart cell execution with mc - Auto-detection of notebook files - Cell navigation with [c and ]c Addresses #57 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- lua/plugins/config/image.lua | 47 +++++++++++ lua/plugins/config/jupytext.lua | 141 ++++++++++++++++++++++++++++++++ lua/plugins/config/molten.lua | 99 +++++++++++++++++++--- lua/plugins/spec/image.lua | 8 ++ lua/plugins/spec/jupytext.lua | 12 +++ lua/plugins/spec/molten.lua | 19 +++++ 6 files changed, 315 insertions(+), 11 deletions(-) create mode 100644 lua/plugins/config/image.lua create mode 100644 lua/plugins/config/jupytext.lua create mode 100644 lua/plugins/spec/image.lua create mode 100644 lua/plugins/spec/jupytext.lua create mode 100644 lua/plugins/spec/molten.lua diff --git a/lua/plugins/config/image.lua b/lua/plugins/config/image.lua new file mode 100644 index 0000000..f3216c9 --- /dev/null +++ b/lua/plugins/config/image.lua @@ -0,0 +1,47 @@ +local M = {} + +function M.setup() + local image = require 'image' + image:setup { + backend = 'kitty', + processor = 'magick_cli', + integrations = { + markdown = { + enabled = true, + clear_in_insert_mode = false, + download_remote_images = true, + only_render_image_at_cursor = false, + only_render_image_at_curser_mode = 'popup', + floating_windows = false, + filetypes = { 'markdown', 'vimwiki' }, + }, + neog = { + enabled = true, + filetypes = { 'norg' }, + }, + typst = { + enabled = true, + filetypes = { 'typst' }, + }, + html = { + enabled = false, + }, + css = { + enabled = false, + }, + }, + max_width = 100, + max_height = 12, + max_width_window_percentage = math.huge, + max_height_window_percentage = math.huge, + scale_factor = 1.0, + window_overlap_clear_enabled = true, + -- window_overlap_clear_ft_ignore = { 'cmp_menu', 'cmp_docs', 'snacks_notif', 'scrollview', 'scrollview_sign' }, + window_overlap_clear_ft_ignore = { 'cmp_menu', 'cmp_docs', '' }, + editor_only_render_when_focused = false, + tmux_show_only_in_active_window = false, + hijack_file_patterns = { '*.png', '*.jpg', '*.jpeg', '*.gif', '*.webp', '*.avif' }, + } +end + +return M diff --git a/lua/plugins/config/jupytext.lua b/lua/plugins/config/jupytext.lua new file mode 100644 index 0000000..0fad9f1 --- /dev/null +++ b/lua/plugins/config/jupytext.lua @@ -0,0 +1,141 @@ +-- Jupytext configuration +local M = {} + +function M.setup() + -- Basic settings + vim.g.jupytext_fmt = 'py:percent' + vim.g.jupytext_command = 'jupytext' + + -- Create user commands + local function create_commands() + vim.api.nvim_create_user_command('JupytextSync', function() + vim.cmd('!jupytext --sync %') + vim.notify("Notebook synced", vim.log.levels.INFO) + end, { desc = "Sync current file with its notebook pair" }) + + vim.api.nvim_create_user_command('JupytextPair', function() + vim.cmd('!jupytext --set-formats ipynb,py:percent %') + vim.notify("Notebook paired with .py file", vim.log.levels.INFO) + end, { desc = "Pair notebook with Python file" }) + + vim.api.nvim_create_user_command('JupytextConvert', function(opts) + local format = opts.args ~= "" and opts.args or "py:percent" + vim.cmd('!jupytext --to ' .. format .. ' %') + vim.notify("Converted to " .. format, vim.log.levels.INFO) + end, { + nargs = '?', + desc = "Convert notebook to format (default: py:percent)", + complete = function() + return { 'py:percent', 'md', 'py:light', 'py:sphinx', 'jl:percent', 'r:percent' } + end + }) + end + + -- Setup auto-sync for paired files + local function setup_autosync() + local augroup = vim.api.nvim_create_augroup("JupytextSync", { clear = true }) + + vim.api.nvim_create_autocmd("BufWritePost", { + group = augroup, + pattern = { "*.py", "*.ipynb", "*.md", "*.jl", "*.r" }, + callback = function() + local file = vim.fn.expand('%:p') + local bufnr = vim.api.nvim_get_current_buf() + + -- Check if file has jupytext pairing metadata + local first_lines = vim.api.nvim_buf_get_lines(bufnr, 0, 15, false) + local has_pairing = false + + for _, line in ipairs(first_lines) do + if line:match("jupytext:") and line:match("formats:") then + has_pairing = true + break + end + end + + if has_pairing then + -- Run sync in background + vim.fn.jobstart({'jupytext', '--sync', file}, { + on_exit = function(_, exit_code) + if exit_code == 0 then + vim.schedule(function() + vim.notify("Jupytext: Synced with notebook", vim.log.levels.INFO) + end) + end + end + }) + end + end, + desc = "Auto-sync Jupytext paired files" + }) + end + + -- Setup keymaps + local function setup_keymaps() + -- Only set these in relevant filetypes + vim.api.nvim_create_autocmd("FileType", { + pattern = { "python", "markdown", "julia", "r" }, + callback = function() + local opts = { buffer = true, silent = true } + vim.keymap.set("n", "js", ":JupytextSync", + vim.tbl_extend("force", opts, { desc = "Sync with notebook" })) + vim.keymap.set("n", "jp", ":JupytextPair", + vim.tbl_extend("force", opts, { desc = "Pair with notebook" })) + vim.keymap.set("n", "jc", ":JupytextConvert ", + vim.tbl_extend("force", opts, { desc = "Convert to format" })) + end + }) + end + + -- Integration with Molten + local function setup_molten_integration() + -- When opening .ipynb files, offer to convert + vim.api.nvim_create_autocmd({"BufRead", "BufNewFile"}, { + pattern = "*.ipynb", + callback = function() + local file = vim.fn.expand('%:p') + local py_file = file:gsub('%.ipynb$', '.py') + + -- Check if .py version exists + if vim.fn.filereadable(py_file) == 1 then + -- Close the current buffer and open the .py version + vim.cmd('buffer #') -- Go to alternate buffer + vim.cmd('bdelete! #') -- Delete the ipynb buffer + vim.cmd('edit ' .. py_file) + vim.notify("Opened paired .py file instead of .ipynb", vim.log.levels.INFO) + else + -- Offer to convert + vim.schedule(function() + vim.ui.select( + { "Convert to .py", "Keep as JSON" }, + { prompt = "Convert notebook to Python?" }, + function(choice) + if choice == "Convert to .py" then + local result = vim.fn.system('jupytext --to py:percent "' .. file .. '"') + if vim.v.shell_error == 0 then + vim.cmd('edit! ' .. py_file) + vim.notify("Converted and opened as .py", vim.log.levels.INFO) + else + vim.notify("Failed to convert: " .. result, vim.log.levels.ERROR) + end + end + end + ) + end) + end + end, + desc = "Handle .ipynb files with Jupytext" + }) + end + + -- Initialize all components + create_commands() + setup_autosync() + setup_keymaps() + setup_molten_integration() +end + +-- Run setup +M.setup() + +return M \ No newline at end of file diff --git a/lua/plugins/config/molten.lua b/lua/plugins/config/molten.lua index 7dc4eb5..93d6ccf 100644 --- a/lua/plugins/config/molten.lua +++ b/lua/plugins/config/molten.lua @@ -1,18 +1,95 @@ +-- Molten (Jupyter) configuration local M = {} function M.setup() -- Keybindings - local map = vim.keymap.set - - map('n', 'Mi', ':MoltenInit', { desc = 'Molten: Initialize' }) - map('n', 'Me', ':MoltenEvaluateOperator', { desc = 'Molten: Evaluate operator' }) - map('v', 'Me', ':MoltenEvaluateVisualgv', { desc = 'Molten: Evaluate visual' }) - map('n', 'Ml', ':MoltenEvaluateLine', { desc = 'Molten: Evaluate line' }) - map('n', 'Mr', ':MoltenReevaluateCell', { desc = 'Molten: Re-evaluate cell' }) - map('n', 'Md', ':MoltenDelete', { desc = 'Molten: Delete cell' }) - map('n', 'Mo', ':MoltenShowOutput', { desc = 'Molten: Show output' }) - map('n', 'Mh', ':MoltenHideOutput', { desc = 'Molten: Hide output' }) - map('n', 'Mx', ':MoltenInterrupt', { desc = 'Molten: Interrupt kernel' }) + local keymap = vim.keymap.set + + -- Basic operations + keymap('n', 'mi', ':MoltenInit', { desc = 'Molten Init', silent = true }) + keymap('n', 'ml', ':MoltenEvaluateLine', { desc = 'Evaluate Line', silent = true }) + keymap('n', 'mr', ':MoltenReevaluateCell', { desc = 'Re-evaluate Cell', silent = true }) + keymap('v', 'mv', ':MoltenEvaluateVisualgv', { desc = 'Evaluate Visual', silent = true }) + + -- Additional useful keymaps + keymap('n', 'md', ':MoltenDelete', { desc = 'Delete Cell', silent = true }) + keymap('n', 'mh', ':MoltenHideOutput', { desc = 'Hide Output', silent = true }) + keymap('n', 'mo', ':noautocmd MoltenEnterOutput', { desc = 'Enter Output', silent = true }) + + -- Better cell execution that always works + keymap('n', 'mc', function() + -- Always use the operator approach for consistency + -- This finds cell boundaries based on # %% markers + vim.cmd 'MoltenEvaluateOperator' + + -- Send 'ip' (inner paragraph) which respects the cell markers + local keys = vim.api.nvim_replace_termcodes('ip', true, false, true) + vim.api.nvim_feedkeys(keys, 'n', false) + end, { desc = 'Run Cell', silent = true }) + + -- Alternative: Run and move to next cell + keymap('n', 'mn', function() + vim.cmd 'MoltenEvaluateOperator' + local keys = vim.api.nvim_replace_termcodes('ip', true, false, true) + vim.api.nvim_feedkeys(keys, 'n', false) + -- Move to next cell + vim.fn.search('^# %%', 'W') + end, { desc = 'Run Cell and Next', silent = true }) + + -- Cell navigation + keymap('n', ']c', ':MoltenNext', { desc = 'Next Cell', silent = true }) + keymap('n', '[c', ':MoltenPrev', { desc = 'Previous Cell', silent = true }) end +-- Setup autocommands +local function setup_autocmds() + local augroup = vim.api.nvim_create_augroup('MoltenConfig', { clear = true }) + + -- Auto-initialize for Python files + vim.api.nvim_create_autocmd('FileType', { + group = augroup, + pattern = { 'python' }, + callback = function() + vim.keymap.set('n', 'ms', function() + vim.cmd 'MoltenInit python3' + vim.notify('Molten kernel started', vim.log.levels.INFO) + end, { buffer = true, desc = 'Start Python Kernel' }) + + -- Auto-detect notebook files with cell markers + vim.schedule(function() + local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) + for _, line in ipairs(lines) do + if line:match '^# %%' then + vim.notify('Notebook detected. Use ms to start kernel', vim.log.levels.INFO) + break + end + end + end) + end, + }) + + -- Better notebook file handling + vim.api.nvim_create_autocmd('BufRead', { + group = augroup, + pattern = { '*.ipynb' }, + callback = function() + -- Consider using jupytext for better .ipynb handling + vim.notify('Notebook file detected. Use :MoltenInit to start kernel', vim.log.levels.INFO) + end, + }) +end + +-- Main setup function +function M.setup() + -- Tell Molten about cell delimiters + vim.g.molten_cell_delimiter = '^# %%' + + setup_keymaps() + setup_autocmds() +end + +-- Initialize +M.setup() + return M + diff --git a/lua/plugins/spec/image.lua b/lua/plugins/spec/image.lua new file mode 100644 index 0000000..8d2f9c5 --- /dev/null +++ b/lua/plugins/spec/image.lua @@ -0,0 +1,8 @@ +-- Render images in neovim +return { + '3rd/image.nvim', + build = false, + opts = { + processor = 'magick_cli', + }, +} diff --git a/lua/plugins/spec/jupytext.lua b/lua/plugins/spec/jupytext.lua new file mode 100644 index 0000000..da401d5 --- /dev/null +++ b/lua/plugins/spec/jupytext.lua @@ -0,0 +1,12 @@ +-- Jupytext - Jupyter notebook as plain text +return { + 'goerz/jupytext.vim', + ft = { 'ipynb', 'python', 'markdown', 'julia', 'r' }, + build = function() + -- Ensure jupytext is installed + vim.fn.system('pip install jupytext') + end, + config = function() + require('plugins.config.jupytext') + end +} \ No newline at end of file diff --git a/lua/plugins/spec/molten.lua b/lua/plugins/spec/molten.lua new file mode 100644 index 0000000..0abec4b --- /dev/null +++ b/lua/plugins/spec/molten.lua @@ -0,0 +1,19 @@ +-- Molten - Jupyter notebook support in Neovim +return { + 'benlubas/molten-nvim', + version = '^1.0.0', + dependencies = { '3rd/image.nvim' }, + build = ':UpdateRemotePlugins', + init = function() + -- Basic settings + vim.g.molten_image_provider = 'image.nvim' + vim.g.molten_output_win_max_height = 20 + vim.g.molten_auto_open_output = true + vim.g.molten_wrap_output = true + vim.g.molten_virt_text_output = true + end, + config = function() + -- Delegate to config file for complex setup + require('plugins.config.molten') + end, +} From 539f9e6b61d50291c0c490ae75d1ec1a096f2bb4 Mon Sep 17 00:00:00 2001 From: dlond Date: Thu, 2 Apr 2026 11:17:16 +1300 Subject: [PATCH 2/2] fix: ready for testing --- lua/plugins/config/image.lua | 4 ++-- lua/plugins/config/jupytext.lua | 10 +++++++--- lua/plugins/config/molten.lua | 5 ++--- lua/plugins/spec/image.lua | 6 +++--- lua/plugins/spec/jupytext.lua | 4 ++-- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/lua/plugins/config/image.lua b/lua/plugins/config/image.lua index f3216c9..807a5e5 100644 --- a/lua/plugins/config/image.lua +++ b/lua/plugins/config/image.lua @@ -11,11 +11,11 @@ function M.setup() clear_in_insert_mode = false, download_remote_images = true, only_render_image_at_cursor = false, - only_render_image_at_curser_mode = 'popup', + only_render_image_at_cursor_mode = 'popup', floating_windows = false, filetypes = { 'markdown', 'vimwiki' }, }, - neog = { + neorg = { enabled = true, filetypes = { 'norg' }, }, diff --git a/lua/plugins/config/jupytext.lua b/lua/plugins/config/jupytext.lua index 0fad9f1..9687ece 100644 --- a/lua/plugins/config/jupytext.lua +++ b/lua/plugins/config/jupytext.lua @@ -45,9 +45,13 @@ function M.setup() -- Check if file has jupytext pairing metadata local first_lines = vim.api.nvim_buf_get_lines(bufnr, 0, 15, false) local has_pairing = false - + local saw_jupytext = false + for _, line in ipairs(first_lines) do - if line:match("jupytext:") and line:match("formats:") then + if line:match("jupytext") then + saw_jupytext = true + end + if saw_jupytext and line:match("formats:") then has_pairing = true break end @@ -138,4 +142,4 @@ end -- Run setup M.setup() -return M \ No newline at end of file +return M diff --git a/lua/plugins/config/molten.lua b/lua/plugins/config/molten.lua index 93d6ccf..db7fe0d 100644 --- a/lua/plugins/config/molten.lua +++ b/lua/plugins/config/molten.lua @@ -1,8 +1,8 @@ -- Molten (Jupyter) configuration local M = {} -function M.setup() - -- Keybindings +-- Keybindings +local function setup_keymaps() local keymap = vim.keymap.set -- Basic operations @@ -92,4 +92,3 @@ end M.setup() return M - diff --git a/lua/plugins/spec/image.lua b/lua/plugins/spec/image.lua index 8d2f9c5..be5bf33 100644 --- a/lua/plugins/spec/image.lua +++ b/lua/plugins/spec/image.lua @@ -2,7 +2,7 @@ return { '3rd/image.nvim', build = false, - opts = { - processor = 'magick_cli', - }, + config = function() + require('plugins.config.image').setup() + end, } diff --git a/lua/plugins/spec/jupytext.lua b/lua/plugins/spec/jupytext.lua index da401d5..52d4cf3 100644 --- a/lua/plugins/spec/jupytext.lua +++ b/lua/plugins/spec/jupytext.lua @@ -8,5 +8,5 @@ return { end, config = function() require('plugins.config.jupytext') - end -} \ No newline at end of file + end, +}