From eef6e132080c5e46ba8c47ecfaca83fa8e0e214e Mon Sep 17 00:00:00 2001 From: Benjamin Culkin Date: Wed, 28 Jan 2026 21:36:12 -0500 Subject: Add various text UI components This adds a variety of text UI components, namely two suites: * One that is geared towards JSON * One that is geared towards Markdown Details to (perhaps) follow later --- .../java/bjc/utils/gui/MarkdownSplitPreview.java | 457 +++++++++++++++++++++ 1 file changed, 457 insertions(+) create mode 100644 base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java (limited to 'base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java') diff --git a/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java new file mode 100644 index 0000000..a06ede0 --- /dev/null +++ b/base/src/main/java/bjc/utils/gui/MarkdownSplitPreview.java @@ -0,0 +1,457 @@ +package bjc.utils.gui; + +import javax.swing.*; +import javax.swing.event.CaretEvent; +import javax.swing.event.CaretListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import javax.swing.text.*; +import java.awt.*; +import java.awt.event.*; +import java.net.URI; + +public final class MarkdownSplitPreview extends JPanel { + + private static final int RENDER_DEBOUNCE_MS = 150; + private static final int CARET_SYNC_DEBOUNCE_MS = 75; + + // If true: when the user scrolls the preview, automatically turn off "Follow + // caret". + private static final boolean AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL = true; + + private final JTextArea editor = new JTextArea(); + private final JTextPane preview = new JTextPane(); + + private final MarkdownEditorKit previewKit = new MarkdownEditorKit(); + + private final Timer renderTimer; + private final Timer caretSyncTimer; + + private String lastRenderedMarkdown = null; + + // Reentrancy guard for programmatic scrollbar updates + private boolean adjustingScrollbars = false; + + // Follow-caret UI state + private final JToggleButton followCaretToggle = new JToggleButton("Follow caret", true); + + public MarkdownSplitPreview() { + super(new BorderLayout()); + + // Toolbar + JToolBar tb = new JToolBar(); + tb.setFloatable(false); + + followCaretToggle.setFocusable(false); + followCaretToggle.setToolTipText("When enabled, the preview tracks where you are editing."); + tb.add(followCaretToggle); + + JButton renderNowButton = new JButton("Render now"); + renderNowButton.setFocusable(false); + renderNowButton.setToolTipText("Force an immediate preview update (Ctrl+Enter / Ctrl+R)."); + tb.addSeparator(); + tb.add(renderNowButton); + + add(tb, BorderLayout.NORTH); + + // Editor (left) + editor.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 13)); + editor.setTabSize(4); + editor.setLineWrap(true); + editor.setWrapStyleWord(true); + + // Preview (right) + preview.setEditable(false); + preview.setEditorKit(previewKit); + + final JScrollPane leftScroll = new JScrollPane(editor); + final JScrollPane rightScroll = new JScrollPane(preview); + + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, leftScroll, rightScroll); + split.setResizeWeight(0.5); + add(split, BorderLayout.CENTER); + + // Debounced render (EDT) + renderTimer = new Timer(RENDER_DEBOUNCE_MS, e -> renderPreview(rightScroll, leftScroll)); + renderTimer.setRepeats(false); + + // Debounced caret-follow (EDT) + caretSyncTimer = new Timer(CARET_SYNC_DEBOUNCE_MS, e -> maybeSyncPreviewToCaret(leftScroll, rightScroll)); + caretSyncTimer.setRepeats(false); + + // Typing/editing triggers render + (optional) caret-follow + editor.getDocument().addDocumentListener(new DocumentListener() { + @Override + public void insertUpdate(DocumentEvent e) { + schedule(); + } + + @Override + public void removeUpdate(DocumentEvent e) { + schedule(); + } + + @Override + public void changedUpdate(DocumentEvent e) { + schedule(); + } + + private void schedule() { + renderTimer.restart(); + caretSyncTimer.restart(); + } + }); + + // Caret movement should also move preview (if follow enabled) + editor.addCaretListener(new CaretListener() { + @Override + public void caretUpdate(CaretEvent e) { + if (!editor.isFocusOwner()) + return; + caretSyncTimer.restart(); + } + }); + + // Button triggers immediate render + renderNowButton.addActionListener(e -> renderNow(rightScroll, leftScroll)); + + installRenderNowHotkeys(rightScroll, leftScroll); + installFollowCaretHotkey(); // Ctrl+L + installLinkHandling(preview); + installScrollSync(leftScroll, rightScroll); + installPreviewManualScrollDetection(rightScroll); + + // Initial render + renderPreview(rightScroll, leftScroll); + } + + public void setMarkdown(String markdown) { + editor.setText(markdown == null ? "" : markdown); + renderTimer.stop(); + caretSyncTimer.stop(); + JScrollPane rightScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, preview); + JScrollPane leftScroll = (JScrollPane) SwingUtilities.getAncestorOfClass(JScrollPane.class, editor); + if (rightScroll != null && leftScroll != null) { + renderPreview(rightScroll, leftScroll); + } else { + previewKit.setMarkdown(preview, markdown == null ? "" : markdown); + lastRenderedMarkdown = markdown; + } + } + + public String getMarkdown() { + return editor.getText(); + } + + public boolean isFollowCaretEnabled() { + return followCaretToggle.isSelected(); + } + + public void setFollowCaretEnabled(boolean enabled) { + followCaretToggle.setSelected(enabled); + } + + // ------------------------------------------------------------------------- + // Render logic + // ------------------------------------------------------------------------- + + private void renderPreview(JScrollPane previewScroll, JScrollPane editorScroll) { + String md = editor.getText(); + if (md == null) + md = ""; + + // Skip rerender if unchanged + if (md.equals(lastRenderedMarkdown)) { + maybeSyncPreviewToCaret(editorScroll, previewScroll); + return; + } + + lastRenderedMarkdown = md; + + try { + previewKit.setMarkdown(preview, md); + } catch (RuntimeException ex) { + preview.setDocument(new DefaultStyledDocument()); + preview.setText("Markdown render error:\n" + ex.getMessage()); + } + + SwingUtilities.invokeLater(() -> maybeSyncPreviewToCaret(editorScroll, previewScroll)); + } + + private void renderNow(JScrollPane previewScroll, JScrollPane editorScroll) { + renderTimer.stop(); + renderPreview(previewScroll, editorScroll); + } + + // ------------------------------------------------------------------------- + // Hotkeys + // ------------------------------------------------------------------------- + + private void installRenderNowHotkeys(JScrollPane previewScroll, JScrollPane editorScroll) { + InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = editor.getActionMap(); + + Action renderNowAction = new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + renderNow(previewScroll, editorScroll); + } + }; + + // Ctrl+Enter + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_ENTER, InputEvent.CTRL_DOWN_MASK), "md.renderNow"); + // Ctrl+R + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_R, InputEvent.CTRL_DOWN_MASK), "md.renderNow"); + + am.put("md.renderNow", renderNowAction); + } + + private void installFollowCaretHotkey() { + InputMap im = editor.getInputMap(JComponent.WHEN_FOCUSED); + ActionMap am = editor.getActionMap(); + + im.put(KeyStroke.getKeyStroke(KeyEvent.VK_L, InputEvent.CTRL_DOWN_MASK), "md.toggleFollowCaret"); + am.put("md.toggleFollowCaret", new AbstractAction() { + @Override + public void actionPerformed(ActionEvent e) { + followCaretToggle.setSelected(!followCaretToggle.isSelected()); + } + }); + } + + // ------------------------------------------------------------------------- + // Clickable links in preview + // ------------------------------------------------------------------------- + + private void installLinkHandling(JTextPane pane) { + pane.addMouseMotionListener(new MouseMotionAdapter() { + @Override + public void mouseMoved(MouseEvent e) { + String href = hrefAtPoint(pane, e.getPoint()); + pane.setCursor( + href != null ? Cursor.getPredefinedCursor(Cursor.HAND_CURSOR) : Cursor.getDefaultCursor()); + } + }); + + pane.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getButton() != MouseEvent.BUTTON1 || e.getClickCount() != 1) + return; + String href = hrefAtPoint(pane, e.getPoint()); + if (href != null) + openLink(href, pane); + } + }); + } + + private static String hrefAtPoint(JTextPane pane, Point p) { + int pos = pane.viewToModel(p); // Java 8 compatible + if (pos < 0) + return null; + + Document d = pane.getDocument(); + if (!(d instanceof StyledDocument)) + return null; + + StyledDocument sd = (StyledDocument) d; + Element elem = sd.getCharacterElement(pos); + if (elem == null) + return null; + + AttributeSet attrs = elem.getAttributes(); + Object href = attrs.getAttribute(MarkdownEditorKit.MarkdownDocument.ATTR_LINK_HREF); + return (href instanceof String) ? (String) href : null; + } + + private static void openLink(String href, Component parent) { + try { + if (!Desktop.isDesktopSupported()) { + Toolkit.getDefaultToolkit().beep(); + return; + } + Desktop.getDesktop().browse(new URI(href)); + } catch (Exception ex) { + JOptionPane.showMessageDialog(parent, "Could not open link:\n" + href + "\n\n" + ex.getMessage(), + "Open Link Failed", JOptionPane.ERROR_MESSAGE); + } + } + + // ------------------------------------------------------------------------- + // Manual preview scroll detection (optionally disables follow-caret) + // ------------------------------------------------------------------------- + + private void installPreviewManualScrollDetection(JScrollPane previewScroll) { + JScrollBar bar = previewScroll.getVerticalScrollBar(); + + // Mouse wheel in preview => manual + MouseWheelListener wheel = e -> onPreviewManualScroll(); + previewScroll.addMouseWheelListener(wheel); + previewScroll.getViewport().addMouseWheelListener(wheel); + + // Scrollbar drag/click => manual + bar.addMouseListener(new MouseAdapter() { + @Override + public void mousePressed(MouseEvent e) { + onPreviewManualScroll(); + } + + @Override + public void mouseReleased(MouseEvent e) { + onPreviewManualScroll(); + } + }); + + // Any user adjustment (not programmatic) => manual + bar.addAdjustmentListener(e -> { + if (adjustingScrollbars) + return; + if (e.getValueIsAdjusting()) + onPreviewManualScroll(); + }); + } + + private void onPreviewManualScroll() { + if (AUTO_DISABLE_FOLLOW_ON_PREVIEW_SCROLL && followCaretToggle.isSelected()) { + followCaretToggle.setSelected(false); + } + } + + // ------------------------------------------------------------------------- + // Scroll sync (proportional, bidirectional) + // ------------------------------------------------------------------------- + + private void installScrollSync(JScrollPane editorScroll, JScrollPane previewScroll) { + JScrollBar eBar = editorScroll.getVerticalScrollBar(); + JScrollBar pBar = previewScroll.getVerticalScrollBar(); + + // Editor scrolled => move preview only if follow is enabled + eBar.addAdjustmentListener(ev -> { + if (adjustingScrollbars) + return; + if (!followCaretToggle.isSelected()) + return; + syncFromTo(eBar, pBar); + }); + + // Preview scrolled => move editor always (user intent) + pBar.addAdjustmentListener(ev -> { + if (adjustingScrollbars) + return; + syncFromTo(pBar, eBar); + }); + } + + private void syncFromTo(JScrollBar from, JScrollBar to) { + int fromRange = scrollRange(from); + int toRange = scrollRange(to); + if (fromRange <= 0 || toRange <= 0) + return; + + double frac = from.getValue() / (double) fromRange; + int target = (int) Math.round(frac * toRange); + target = clamp(target, 0, toRange); + + if (to.getValue() == target) + return; + + adjustingScrollbars = true; + try { + to.setValue(target); + } finally { + adjustingScrollbars = false; + } + } + + private static int scrollRange(JScrollBar bar) { + int range = bar.getMaximum() - bar.getVisibleAmount(); + return Math.max(0, range); + } + + private static int clamp(int v, int lo, int hi) { + if (v < lo) + return lo; + if (v > hi) + return hi; + return v; + } + + // ------------------------------------------------------------------------- + // Caret-follow preview syncing (when enabled) + // ------------------------------------------------------------------------- + + private void maybeSyncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) { + if (!followCaretToggle.isSelected()) + return; + if (!editor.isFocusOwner()) + return; + + syncPreviewToCaret(editorScroll, previewScroll); + } + + private void syncPreviewToCaret(JScrollPane editorScroll, JScrollPane previewScroll) { + JScrollBar pBar = previewScroll.getVerticalScrollBar(); + int pRange = scrollRange(pBar); + if (pRange <= 0) + return; + + int caret = editor.getCaretPosition(); + Rectangle caretRect; + try { + caretRect = editor.modelToView(caret); // Java 8 + } catch (BadLocationException e) { + return; + } + if (caretRect == null) + return; + + int prefHeight = editor.getPreferredSize().height; + int visibleHeight = editorScroll.getViewport().getExtentSize().height; + + int eRange = Math.max(0, prefHeight - visibleHeight); + if (eRange <= 0) + return; + + double frac = caretRect.y / (double) eRange; + if (frac < 0) + frac = 0; + if (frac > 1) + frac = 1; + + int target = (int) Math.round(frac * pRange); + target = clamp(target, 0, pRange); + + if (pBar.getValue() == target) + return; + + adjustingScrollbars = true; + try { + pBar.setValue(target); + } finally { + adjustingScrollbars = false; + } + } + + // ------------------------------------------------------------------------- + // Demo + // ------------------------------------------------------------------------- + + public static void main(String[] args) { + SwingUtilities.invokeLater(() -> { + JFrame f = new JFrame("Markdown Editor + Preview"); + f.setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); + + MarkdownSplitPreview panel = new MarkdownSplitPreview(); + panel.setMarkdown("# Hello\n\n" + "Type Markdown on the left.\n\n" + + "A link: [example](https://example.com)\n\n" + "| Col A | Col B |\n" + "|------:|:-----|\n" + + "| **bold** | `code` |\n" + "| [link](https://example.com) | *italic* |\n\n" + + "If you scroll the preview, Follow caret will turn off.\n" + + "Toggle it back on with the button or Ctrl+L.\n"); + + f.setContentPane(panel); + f.setSize(1100, 700); + f.setLocationRelativeTo(null); + f.setVisible(true); + }); + } +} -- cgit v1.2.3