mirror of
https://gitlab.com/biskuteri-cafe/JKomasto2.git
synced 2024-11-20 05:14:50 +01:00
e6fea4c061
(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.)
415 lines
8.6 KiB
Java
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);
|
|
}
|
|
|
|
}
|