mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2024-11-20 06:34:49 +01:00
415 lines
8.6 KiB
Java
Executable File
415 lines
8.6 KiB
Java
Executable File
|
|
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);
|
|
}
|
|
|
|
}
|