/* 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 javax.swing.ImageIcon; import java.awt.Graphics; import java.awt.FontMetrics; import java.awt.Image; import java.awt.Color; import java.awt.Dimension; import java.util.List; import java.util.LinkedList; import java.util.ListIterator; 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; class RichTextPane extends JComponent implements MouseListener, MouseMotionListener, KeyListener { private List text; private int selectionStart, selectionEnd; // ---%-@-%--- public void setText(List text) { this.text = text; selectionStart = selectionEnd = -1; } public List getSelection() { List returnee = new LinkedList<>(); if (selectionEnd == -1) return returnee; if (selectionEnd < selectionStart) { int t = selectionEnd; selectionEnd = selectionStart; selectionStart = t; } returnee.addAll(text.subList(selectionStart + 1, selectionEnd)); return returnee; } public void copySelection() { assert selectionEnd != -1; StringBuilder b = new StringBuilder(); for (Segment segment: getSelection()) { if (segment.link != null) b.append(segment.link); else if (segment.text != null) b.append(segment.text); } ClipboardApi.serve(b.toString()); } // - -%- - protected void paintComponent(Graphics g) { g.setFont(getFont()); FontMetrics fm = g.getFontMetrics(getFont()); if (isOpaque()) g.clearRect(0, 0, getWidth(), getHeight()); ((java.awt.Graphics2D)g).setRenderingHint( java.awt.RenderingHints.KEY_TEXT_ANTIALIASING, java.awt.RenderingHints.VALUE_TEXT_ANTIALIAS_ON ); int o = 0; for (Segment segment: text) { if (segment.image != null) { int ow = segment.image.getIconWidth(); int oh = segment.image.getIconHeight(); int h = fm.getAscent() + fm.getDescent(); int w = h * ow / oh; int x = segment.x; int y = segment.y + fm.getDescent(); // Interpret segment.y as specifying text baseline Image img = segment.image.getImage(); g.drawImage(img, x, y - h, w, h, this); continue; } if (o > selectionStart && o < selectionEnd) { int dx = fm.stringWidth(segment.text); int dy1 = fm.getAscent(); int dy2 = dy1 + fm.getDescent(); g.setColor(new Color(0, 0, 0, 15)); g.fillRect(segment.x, segment.y - dy1, dx, dy2); g.setColor(getForeground()); } if (segment.link != null) g.setColor(Color.BLUE); g.drawString(segment.text, segment.x, segment.y); g.setColor(getForeground()); ++o; } } public void mousePressed(MouseEvent eM) { requestFocusInWindow(); selectionStart = identify(eM.getX(), eM.getY()) - 2; selectionEnd = -1; repaint(); } public void mouseDragged(MouseEvent eM) { selectionEnd = identify(eM.getX(), eM.getY()); repaint(); } private int identify(int x, int y) { FontMetrics fm = getFontMetrics(getFont()); int iy = fm.getAscent(); int lh = fm.getAscent() + fm.getDescent(); y -= fm.getDescent(); if (y <= iy) y = iy; else y += lh - ((y - iy) % lh); /* * Snaps y to the next baseline. Kind of obtuse, * but it wasn't randomly derived, anyways * you can test it for 13, 30, 47, etc. */ int o = 0; for (Segment segment: text) { if (segment.y == y && segment.x > x) break; if (segment.y > y) break; ++o; } return o; } public void keyPressed(KeyEvent eK) { if (selectionEnd == -1) return; if (eK.getKeyCode() != KeyEvent.VK_C) return; if (!eK.isControlDown()) return; copySelection(); } public void keyReleased(KeyEvent eK) { } public void keyTyped(KeyEvent eK) { } public void mouseClicked(MouseEvent eM) { } public void mouseReleased(MouseEvent eM) { } public void mouseEntered(MouseEvent eM) { } public void mouseExited(MouseEvent eM) { } public void mouseMoved(MouseEvent eM) { } // - -%- - public static List layout(List text, FontMetrics fm, int width) { List copy = new LinkedList<>(); for (Segment segment: text) copy.add(segment.clone()); text = copy; ListIterator cursor = text.listIterator(); int x = 0, y = fm.getAscent(); int dy = fm.getAscent() + fm.getDescent(); while (cursor.hasNext()) { Segment curr = cursor.next(); int dx; if (curr.image != null) { int ow = curr.image.getIconWidth(); int oh = curr.image.getIconHeight(); int nh = fm.getAscent() + fm.getDescent(); dx = nh * ow / oh; } else if (curr.text != null) { dx = fm.stringWidth(curr.text); } else if (curr.link != null) { curr.text = curr.link; dx = fm.stringWidth(curr.link); } else { assert false; dx = 0; } boolean fits = x + dx < width; if (fits || curr.spacer) { curr.x = x; curr.y = y; x += dx; if (curr.spacer && curr.text.equals("\n")) { y += dy; x = 0; } continue; } boolean tooLong = dx > width; boolean canFitChar = width >= fm.getMaxAdvance(); boolean splittable = curr.image == null; /* * A bit of redundancy in my conditions, but the point is * to exactly express the triggers in my mental model. * The conditions should read more like English. */ if (!tooLong || (tooLong && !splittable)) { curr.x = 0; curr.y = y += dy; x = dx; continue; } assert tooLong && splittable; String s = curr.text; int splitOffset; for (splitOffset = 0; splitOffset < s.length(); ++splitOffset) { String substring = s.substring(0, splitOffset + 1); if (fm.stringWidth(substring) > width) break; } if (splitOffset == 0) splitOffset = 1; /* * I force a split even if our width supports no characters. * Because if I don't split, the only alternatives to infinitely * looping downwards is to emplace this segment or ignore it. */ Segment fitted = new Segment(); fitted.text = s.substring(0, splitOffset); fitted.link = curr.link; fitted.x = x; fitted.y = y; cursor.add(fitted); curr.text = s.substring(splitOffset); y += dy; x = 0; cursor.add(curr); cursor.previous(); /* * I had to use a stack and return a new list because, * splitting can turn a long segment into several spread * over different lines. Here curr becomes the "after-split" * and I push it back to the stack. * * If #layout wasn't a separate method, but rather only for * graphical painting. Then I don't need a stack nor return * a new list. I iterate over the given one, filling the * nodes' geometric information, if a split occurs I save the * "after-split" in a variable, which in the next iteration * I use as curr instead of list.next(). The caller doesn't * need to know the geometry of these intermediate segments. */ continue; } return text; } // ---%-@-%--- public static class Segment { public ImageIcon image; public String link; public String text; public boolean spacer; public int x, y; // -=%=- public String toString() { StringBuilder b = new StringBuilder(); b.append(getClass().getName() + "["); b.append("image=" + image); b.append(",link=" + link); b.append(",text=" + text); b.append(",x=" + x); b.append(",y=" + y); b.append("]"); return b.toString(); } public Segment clone() { Segment segment = new Segment(); segment.image = this.image; segment.link = this.link; segment.text = this.text; segment.spacer = this.spacer; segment.x = this.x; segment.y = this.y; return segment; } } public static class Builder { private List returnee; // -=%=- public Builder() { returnee = new LinkedList<>(); } public Builder image(ImageIcon image, String text) { Segment segment = new Segment(); segment.image = image; segment.text = text; returnee.add(segment); return this; } public Builder link(String link, String text) { Segment segment = new Segment(); segment.link = link; segment.text = text; returnee.add(segment); return this; } public Builder text(String text) { Segment segment = new Segment(); segment.text = text; returnee.add(segment); return this; } public Builder spacer(String text) { Segment segment = new Segment(); segment.text = text; segment.spacer = true; returnee.add(segment); return this; } public List finish() { return returnee; } } // ---%-@-%--- RichTextPane() { text = new LinkedList<>(); addMouseListener(this); addMouseMotionListener(this); addKeyListener(this); } }