biskuteri-cafe-JKomasto2/RichTextPane.java
Snowyfox e6fea4c061 Fixed bug when redraft makes no changes
(Before this, JKomasto and sometimes the Mastodon web client would get '411 Record Not Found' when submitting the same text after deleting and redrafting. Presumably the Mastodon server caches both whether an idempotency key was fulfilled and which post it leads to, and for some reason it looks up the second and fails.)
2022-05-31 03:39:56 -04:00

415 lines
8.6 KiB
Java

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);
}
}