biskuteri-cafe-JKomasto2/RichTextPane.java

434 lines
9.3 KiB
Java

/* copyright
This file is part of JKomasto2.
Written in 2022 by Usawashi <usawashi16@yahoo.co.jp>
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 <https://www.gnu.org/licenses/>.
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<Segment>
text;
private int
selectionStart, selectionEnd;
// ---%-@-%---
public void
setText(List<Segment> text)
{
this.text = text;
selectionStart = selectionEnd = -1;
}
public List<Segment>
getSelection()
{
List<Segment> 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<Segment>
layout(List<Segment> text, FontMetrics fm, int width)
{
List<Segment> copy = new LinkedList<>();
for (Segment segment: text) copy.add(segment.clone());
text = copy;
ListIterator<Segment> 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<Segment>
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<Segment>
finish() { return returnee; }
}
// ---%-@-%---
RichTextPane()
{
text = new LinkedList<>();
addMouseListener(this);
addMouseMotionListener(this);
addKeyListener(this);
}
}