/* copyright This file is part of JKomasto2. Written in 2022 by Usawashi This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details. You should have received a copy of the GNU General Public License along with this program. If not, see . copyright */ import javax.swing.JComponent; import java.awt.Graphics; import java.awt.FontMetrics; import java.awt.Font; import java.awt.Image; import java.awt.Color; import java.awt.Dimension; import java.awt.event.ComponentListener; import java.awt.event.ComponentEvent; import java.awt.event.MouseListener; import java.awt.event.MouseMotionListener; import java.awt.event.MouseEvent; import java.awt.event.KeyListener; import java.awt.event.KeyEvent; import java.util.Map; import java.util.HashMap; import java.util.List; import java.util.LinkedList; import java.util.ArrayList; import cafe.biskuteri.hinoki.Tree; class RichTextPane3 extends JComponent implements ComponentListener, MouseListener, MouseMotionListener, KeyListener { private Tree html; private Map emojis; private Map, Position> layout; private Tree layoutEnd, selStart, selEnd; private int startingLine, lastLine; // ---%-@-%--- public void setText(Tree html) { assert html != null; this.html = html; if (!isValid()) return; assert html.key != null; assert html.key.equals("tag"); assert html.get("children") != null; FontMetrics fm = getFontMetrics(getFont()); Position cursor = new Position(0, 1); // Manually negate if first element is a break. Tree children = html.get("children"); if (children.size() > 0) { Tree first = children.get(0); if (first.key.equals("tag")) { String tagName = first.get(0).key; if (tagName.equals("br")) cursor.line -= 1; if (tagName.equals("p")) cursor.line -= 2; } } selStart = selEnd = null; layout.clear(); startingLine = 1; layout(html, fm, cursor); layout.put(layoutEnd, cursor.clone()); lastLine = cursor.line; repaint(); int iy = fm.getAscent(); int lh = fm.getAscent() + fm.getDescent(); int h = snap2(cursor.line, iy, lh); h += fm.getDescent(); setPreferredSize(new Dimension(1, h)); } public void setEmojis(Map emojis) { assert emojis != null; this.emojis = emojis; setText(html); } public void previousPage() { int advance = getHeightInLines(); if (startingLine < advance) startingLine = 1; else startingLine -= advance; repaint(); } public void nextPage() { int advance = getHeightInLines(); if (lastLine - startingLine < advance) return; else startingLine += advance; repaint(); } // - -%- - private void layout(Tree node, FontMetrics fm, Position cursor) { assert cursor != null; if (node.key.equals("space")) { int w = fm.stringWidth(node.value); if (cursor.x + w < getWidth()) { layout.put(node, cursor.clone()); cursor.x += w; } else { layout.put(node, cursor.clone()); ++cursor.line; cursor.x = 0; } } else if (node.key.equals("text")) { int w = fm.stringWidth(node.value); if (cursor.x + w < getWidth()) { layout.put(node, cursor.clone()); cursor.x += w; } else if (w < getWidth()) { ++cursor.line; cursor.x = 0; layout.put(node, cursor.clone()); cursor.x += w; } else { StringBuilder rem = new StringBuilder(); rem.append(node.value); int mw = getWidth(); int aw = mw - cursor.x; w = fm.charWidth(node.value.charAt(0)); if (w >= aw) { ++cursor.line; cursor.x = 0; } while (rem.length() > 0) { int l = 2; for (; l <= rem.length(); ++l) { String substr = rem.substring(0, l); w = fm.stringWidth(substr); if (w >= aw) break; } String substr = rem.substring(0, --l); w = fm.stringWidth(substr); Tree temp = new Tree<>(); temp.key = node.key; temp.value = substr; layout.put(temp, cursor.clone()); rem.delete(0, l); boolean more = rem.length() != 0; if (more) ++cursor.line; cursor.x = more ? 0 : w; aw = mw; } } } else if (node.key.equals("emoji")) { Image image = emojis.get(node.value); int w; if (image != null) { int ow = image.getWidth(this); int oh = image.getHeight(this); int h = fm.getAscent() + fm.getDescent(); w = ow * h/oh; } else { w = fm.stringWidth(node.value); } if (cursor.x + w < getWidth()) { layout.put(node, cursor.clone()); cursor.x += w; } else { ++cursor.line; cursor.x = 0; layout.put(node, cursor.clone()); cursor.x += w; } } else if (node.key.equals("tag")) { String tagName = node.get(0).key; Tree children = node.get("children"); // We won't place tag nodes on the layout. if (tagName.equals("br")) { ++cursor.line; cursor.x = 0; } else if (tagName.equals("p")) { //cursor.line += 3/2; cursor.line += 2; // We don't have vertical cursor movement // other than the line. Maybe fix in the // future..? cursor.x = 0; } for (Tree child: children) { // Shallow copy this child node, Tree aug = new Tree<>(); aug.key = child.key; aug.value = child.value; for (Tree gc: child) aug.add(gc); // Append all of our attributes. We'd like // those like href to end up at the text // nodes. This might collide with our // child node's attributes, for now I'll // assume that's not an issue. for (int o = 1; o < node.size(); ++o) { Tree attr = node.get(o); if (attr == children) continue; aug.add(attr); } layout(aug, fm, cursor); } } else assert false; } protected void paintComponent(Graphics g) { final Color LINK_COLOUR = Color.BLUE; final Color PLAIN_COLOUR = getForeground(); final Color SEL_COLOUR = new Color(0, 0, 0, 25); g.setFont(getFont()); FontMetrics fm = g.getFontMetrics(); int iy = fm.getAscent(); int lh = fm.getAscent() + fm.getDescent(); int asc = fm.getAscent(); int w = getWidth(), h = getHeight(); if (isOpaque()) g.clearRect(0, 0, w, h); if (selEnd != null) { Position ssp = layout.get(selStart); assert ssp != null; Position sep = layout.get(selEnd); assert sep != null; /* * (知) One way these can go null is if we clear * the layout but don't clear the selection. */ if (ssp.compareTo(sep) > 0) { Position temp = ssp; ssp = sep; sep = ssp; } int ls = 1 + ssp.line - startingLine; int le = 1 + sep.line - startingLine; int ys = snap2(ls, iy, lh) - asc; int ye = snap2(le, iy, lh) - asc; g.setColor(SEL_COLOUR); if (ssp.line == sep.line) { g.fillRect(ssp.x, ys, sep.x - ssp.x, lh); } else { g.fillRect(ssp.x, ys, w - ssp.x, lh); for (int l = ls + 1; l < le; ++l) { int y = snap2(l, iy, lh) - asc; g.fillRect(0, y, w, lh); } g.fillRect(0, ye, sep.x, lh); } } ((java.awt.Graphics2D)g).setRenderingHint( java.awt.RenderingHints.KEY_ANTIALIASING, java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); g.setColor(getForeground()); for (Tree node: layout.keySet()) { Position position = layout.get(node); int x = position.x; int line = 1 + position.line - startingLine; int y = snap2(line, iy, lh); if (y > h) continue; if (node.key.equals("text")) { boolean isLink = node.get("href") != null; if (isLink) g.setColor(LINK_COLOUR); g.drawString(node.value, x, y); if (isLink) g.setColor(PLAIN_COLOUR); } else if (node.key.equals("emoji")) { Image image = emojis.get(node.value); Image scaled = emojis.get(node.value + "_scaled"); if (scaled != null) { y -= asc; g.drawImage(scaled, x, y, this); } else if (image != null) { scaled = image.getScaledInstance( -1, fm.getAscent() + fm.getDescent(), Image.SCALE_SMOOTH ); // I hope #getScaledInstance knows how to // wait if the image is yet to be loaded. emojis.put(node.value + "_scaled", scaled); } else { g.drawString(node.value, x, y); } } else continue; } } public void mousePressed(MouseEvent eM) { if (eM.getButton() != MouseEvent.BUTTON1) return; selStart = identifyNodeAt(eM.getX(), eM.getY()); selEnd = null; repaint(); requestFocusInWindow(); } public void mouseDragged(MouseEvent eM) { if (selStart == null) return; selEnd = identifyNodeAfter(eM.getX(), eM.getY()); if (selEnd == null) selEnd = layoutEnd; repaint(); } public void mouseMoved(MouseEvent eM) { Tree h = identifyNodeAt(eM.getX(), eM.getY()); if (h == null || h.get("href") == null) { setToolTipText(""); } else { setToolTipText(h.get("href").value); } } private Tree identifyNodeAt(int x, int y) { FontMetrics fm = getFontMetrics(getFont()); int initial = fm.getAscent(); int advance = fm.getAscent() + fm.getDescent(); int line = isnap2(y, initial, advance); Tree returnee = null; Position closest = new Position(0, 0); for (Tree node: layout.keySet()) { Position position = layout.get(node); assert position != null; if (position.line != line) continue; if (position.x > x) continue; if (position.x >= closest.x) { returnee = node; closest = position; } } return returnee; } private Tree identifyNodeAfter(int x, int y) { FontMetrics fm = getFontMetrics(getFont()); int initial = fm.getAscent(); int advance = fm.getAscent() + fm.getDescent(); int line = isnap2(y, initial, advance); Tree returnee = null; Position closest = new Position(Integer.MAX_VALUE, 0); for (Tree node: layout.keySet()) { Position position = layout.get(node); assert position != null; if (position.line != line) continue; if (position.x < x) continue; if (position.x < closest.x) { returnee = node; closest = position; } } return returnee; } public void keyPressed(KeyEvent eK) { if (!eK.isControlDown()) return; switch (eK.getKeyCode()) { case KeyEvent.VK_C: if (selEnd == null) return; ClipboardApi.serve(getSelectedText()); return; case KeyEvent.VK_A: selStart = identifyNodeAt(0, 0); selEnd = layoutEnd; repaint(); return; } } private String getSelectedText() { assert selStart != null && selEnd != null; Position ssp = layout.get(selStart); Position sep = layout.get(selEnd); assert ssp != null && sep != null; if (ssp.compareTo(sep) > 0) { Position temp = ssp; ssp = sep; sep = ssp; } List> selected = new ArrayList<>(); List positions = new ArrayList<>(); for (Tree node: layout.keySet()) { Position position = layout.get(node); assert position != null; boolean after = position.compareTo(ssp) >= 0; boolean before = position.compareTo(sep) < 0; if (!(after && before)) continue; // Just throw them in a pile for now.. selected.add(node); positions.add(position); } // Now sort them into reading order. Tree n1, n2; Position p1, p2; for (int eo = 1; eo < positions.size(); ++eo) for (int o = positions.size() - 1; o >= eo; --o) { n1 = selected.get(o - 1); n2 = selected.get(o); p1 = positions.get(o - 1); p2 = positions.get(o); if (p2.compareTo(p1) > 0) continue; selected.set(o - 1, n2); selected.set(o, n1); positions.set(o - 1, p2); positions.set(o, p1); } StringBuilder b = new StringBuilder(); for (Tree node: selected) { boolean t = node.key.equals("text"); boolean e = node.key.equals("emoji"); boolean s = node.key.equals("space"); assert t || e || s; b.append(node.value); /* * I actually want to copy the link if the node is * associated with one. However, a link has * multiple text nodes, so I'd end up copying * multiple times. The correct action is to * associate the nodes with the same link object, * then mark that as copied. Or, associate the * nodes with their superiors in the HTML, then * walk up until we find an anchor with a href. * Then again, have to mark that as copied too. * * I can also walk the HTML and copy any that are * in the selected region, careful to copy an * anchor's href in stead of the anchor contents. * I'd need a guarantee that my walking order is * the same as how they were rendered on the screen. */ } return b.toString(); } private int getHeightInLines() { FontMetrics fm = getFontMetrics(getFont()); int initial = fm.getAscent(); int advance = fm.getAscent() + fm.getDescent(); return isnap2(getHeight(), initial, advance) - 1; } public void keyReleased(KeyEvent eK) { } public void keyTyped(KeyEvent eK) { } public void mouseReleased(MouseEvent eM) { } public void mouseClicked(MouseEvent eM) { } public void mouseEntered(MouseEvent eM) { } public void mouseExited(MouseEvent eM) { } public void componentResized(ComponentEvent eC) { setText(html); } public void componentMoved(ComponentEvent eC) { } public void componentShown(ComponentEvent eC) { } public void componentHidden(ComponentEvent eC) { } // - -%- - private static int snap2(int blocks, int initial, int advance) { return initial + (blocks - 1) * advance; // If you'd like to go behind the first line 1, // note that the first negative line is 0. } private static int isnap2(int units, int initial, int advance) { int offset = units - initial; return 2 + bfloor(offset - 1, advance); // Not yet sure how this behaves for negative numbers. } private static int bfloor(int units, int block) { if (units < 0) return (units / block) - 1; else return units / block; } // ---%-@-%--- private static class Position { int x, line; // -=%=- public int compareTo(Position other) { if (line < other.line) return -1; if (line > other.line) return 1; if (x < other.x) return -1; if (x > other.x) return 1; return 0; } public String toString() { return "(" + x + "," + line + ")"; } // -=%=- public Position(int x, int line) { this.x = x; this.line = line; } public Position clone() { return new Position(x, line); } } // ---%-@-%--- RichTextPane3() { layout = new HashMap<>(); layoutEnd = new Tree<>("text", ""); emojis = new HashMap<>(); Tree blank = new Tree<>(); blank.key = "tag"; blank.add(new Tree<>("html", null)); blank.add(new Tree<>("children", null)); setText(blank); this.addComponentListener(this); this.addMouseListener(this); this.addMouseMotionListener(this); this.addKeyListener(this); setFocusable(true); // A keyboard user can still copy by tabbing in // and selecting all. } }