diff --git a/BasicHTMLParser.java b/BasicHTMLParser.java index 3d70e0e..d62b5f6 100644 --- a/BasicHTMLParser.java +++ b/BasicHTMLParser.java @@ -15,7 +15,7 @@ BasicHTMLParser { { List segments; segments = distinguishTagsFromPcdata(html); - + Tree document; document = toNodes(segments); document = splitText(document); @@ -151,16 +151,18 @@ BasicHTMLParser { assert node.key.equals("text"); StringBuilder b = new StringBuilder(); - boolean letter = false, cletter; + boolean alnum = false, calnum; boolean space = false, cspace; boolean emoji = false; for (char c: node.value.toCharArray()) { - cletter = isLetter(c); + calnum = isMastodonAlnum(c); cspace = Character.isWhitespace(c); - if (c == ':' && !emoji && !letter) + if (c == ':' && !emoji) { + // See note on #isMastodonAlnum. + if (b.length() > 0) { Tree addee = new Tree<>(); @@ -173,7 +175,7 @@ BasicHTMLParser { } else if (c == ':' && emoji) { - assert letter; + assert !space; b.append(c); Tree addee = new Tree<>(); addee.key = "emoji"; @@ -190,24 +192,17 @@ BasicHTMLParser { * an empty emoji is the correct action. */ emoji = false; - cletter = false; + calnum = false; } - else if (cspace && letter) + else if (cspace != space) { - assert b.length() > 0; - Tree addee = new Tree<>(); - addee.key = "text"; - addee.value = empty(b); - returnee.add(addee); - b.append(c); - } - else if (cletter && space) - { - assert b.length() > 0; - Tree addee = new Tree<>(); - addee.key = "space"; - addee.value = empty(b); - returnee.add(addee); + if (b.length() > 0) + { + Tree addee = new Tree<>(); + addee.key = space ? "space" : "text"; + addee.value = empty(b); + returnee.add(addee); + } b.append(c); } else @@ -219,8 +214,8 @@ BasicHTMLParser { * characters like \n, but I'll opt not to. */ - letter = cletter; - space = cspace; + alnum = calnum; + space = cspace; } if (b.length() > 0) { @@ -257,7 +252,7 @@ BasicHTMLParser { root.key = "tag"; root.add(new Tree<>("html", null)); root.add(new Tree<>("children", null)); - + Deque> parents = new LinkedList<>(); parents.push(root); for (Tree node: nodes) @@ -315,25 +310,20 @@ BasicHTMLParser { } private static boolean - isPunctuation(char c) + isMastodonAlnum(char c) { - switch (Character.getType(c)) - { - case Character.START_PUNCTUATION: - case Character.END_PUNCTUATION: - case Character.DASH_PUNCTUATION: - case Character.CONNECTOR_PUNCTUATION: - case Character.INITIAL_QUOTE_PUNCTUATION: - case Character.FINAL_QUOTE_PUNCTUATION: - case Character.OTHER_PUNCTUATION: return true; - default: return false; - } - } - - private static boolean - isLetter(char c) - { - return Character.isLetter(c) || isPunctuation(c); + return Character.isLetterOrDigit(c); + /* + * Not joking. Mastodon is using the POSIX :alnum: regex + * character class here (/app/lib/emoji_formatter.rb; + * ruby-doc§Regexp). It prevents emojis preceeded by + * Japanese like さ too, but not punctuation like tildes + * or full stops. This is server-enforced, the web client + * does string substitution and supports anything. + * (To see this, make a post with an emoji preceeded + * by text, then try again with the same emoji also + * present elsewhere in the post at a valid position.) + */ } private static String @@ -374,4 +364,4 @@ BasicHTMLParser { if (!v.isEmpty()) whole.append(v); return whole.toString(); } -} \ No newline at end of file +} diff --git a/PostWindow.java b/PostWindow.java index 0166e2d..bc750db 100644 --- a/PostWindow.java +++ b/PostWindow.java @@ -140,6 +140,7 @@ PostWindow extends JFrame { emojis.put(entry[0], ImageApi.remote(entry[1])); } test2.setEmojis(emojis); + test2.requestFocusInWindow(); } public void diff --git a/RichTextPane3.java b/RichTextPane3.java index 9cda6f5..fc5fd7f 100644 --- a/RichTextPane3.java +++ b/RichTextPane3.java @@ -5,17 +5,26 @@ import java.awt.Point; import java.awt.FontMetrics; import java.awt.Font; import java.awt.Image; +import java.awt.Color; import java.awt.event.ComponentListener; import java.awt.event.ComponentEvent; +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; import java.util.Map; import java.util.HashMap; import java.util.List; import java.util.LinkedList; +import java.util.ArrayList; import cafe.biskuteri.hinoki.Tree; class RichTextPane3 extends JComponent -implements ComponentListener { +implements + ComponentListener, + MouseListener, MouseMotionListener, KeyListener { private Tree html; @@ -26,23 +35,46 @@ implements ComponentListener { private Map, Point> layout; + private Tree + layoutEnd, selStart, selEnd; + // ---%-@-%--- public void setText(Tree html) { - assert html != null; + assert html != null; this.html = html; if (!isValid()) return; + assert html.key.equals("tag"); + assert html.get("children") != null; + FontMetrics fm = getFontMetrics(getFont()); int iy = fm.getAscent(); - int fph = (fm.getAscent() + fm.getDescent()) * 3/2; - Point cursor = new Point(0, iy - fph); + Point cursor = new Point(0, iy); + + Tree nodes = html.get("children"); + if (nodes.size() > 0) + { + Tree first = nodes.get(0); + if (first.key.equals("tag")) + { + String tagName = first.get(0).key; + if (tagName.equals("p")) + { + int lh = fm.getAscent() + fm.getDescent(); + cursor.y -= lh * 2; + } + } + } + + selStart = selEnd = null; layout.clear(); layout(html, fm, cursor); + layout.put(layoutEnd, new Point(cursor)); repaint(); } @@ -59,6 +91,7 @@ implements ComponentListener { private void layout(Tree node, FontMetrics fm, Point cursor) { + assert cursor != null; int lh = fm.getAscent() + fm.getDescent(); if (node.key.equals("space")) @@ -104,7 +137,7 @@ implements ComponentListener { cursor.y += lh; cursor.x = 0; } - + while (rem.length() > 0) { int l = 2; @@ -116,7 +149,7 @@ implements ComponentListener { } String substr = rem.substring(0, --l); w = fm.stringWidth(substr); - + Tree temp = new Tree<>(); temp.key = node.key; temp.value = substr; @@ -169,31 +202,43 @@ implements ComponentListener { String tagName = node.get(0).key; Tree children = node.get("children"); + // We won't place tag nodes on the layout. + if (tagName.equals("br")) { - layout.put(node, new Point(cursor)); cursor.y += lh; cursor.x = 0; } else if (tagName.equals("p")) { - layout.put(node, new Point(cursor)); - cursor.y += lh * 3/2; + //cursor.y += lh * 3/2; + cursor.y += lh * 2; + // Our selection algorithm assumes equidistant + // lines. Laziest fix is collect and sort line + // Ys from height. cursor.x = 0; } - else if (tagName.equals("a")) - { - layout.put(node, new Point(cursor)); - // For now we ignore the link. - } - else if (tagName.equals("span")) - { - layout.put(node, new Point(cursor)); - } for (Tree child: children) { - layout(child, fm, cursor); + // Shallow copy this child node. + Tree aug = new Tree<>(); + aug.key = child.key; + aug.value = child.value; + for (Tree gc: child) aug.add(gc); + + // Append all of our attributes. We'd like those + // like href to end up at the text nodes. This + // might collide with our child node's attributes, + // for now I'll assume that's not an issue. + for (int o = 1; o < node.size(); ++o) + { + Tree attr = node.get(o); + if (attr == children) continue; + aug.add(attr); + } + + layout(aug, fm, cursor); } } else assert false; @@ -202,6 +247,10 @@ implements ComponentListener { protected void paintComponent(Graphics g) { + final Color LINK_COLOUR = Color.BLUE; + final Color PLAIN_COLOUR = getForeground(); + final Color SEL_COLOUR = new Color(0, 0, 0, 25); + g.setFont(getFont()); FontMetrics fm = g.getFontMetrics(); @@ -210,16 +259,59 @@ implements ComponentListener { java.awt.RenderingHints.VALUE_ANTIALIAS_ON ); + if (selEnd != null) + { + Point ssp = layout.get(selStart); + assert ssp != null; + Point sep = layout.get(selEnd); + assert sep != null; + /* + * (知) One way these can go null is if we clear + * the layout but don't clear the selection. + */ + + boolean flip = ssp.y > sep.y; + flip |= sep.y == ssp.y && sep.x < ssp.x; + if (flip) + { + Point temp = ssp; + ssp = sep; + sep = ssp; + } + + int w = getWidth(); + int asc = fm.getAscent(); + int lh = fm.getAscent() + fm.getDescent(); + g.setColor(SEL_COLOUR); + if (ssp.y == sep.y) + { + g.fillRect(ssp.x, ssp.y - asc, sep.x - ssp.x, lh); + } + else + { + g.fillRect(ssp.x, ssp.y - asc, w - ssp.x, lh); + for (int y = ssp.y + lh; y < sep.y; y += lh) + { + g.fillRect(0, y - asc, w, lh); + } + g.fillRect(0, sep.y - asc, sep.x, lh); + } + } + + g.setColor(getForeground()); for (Tree node: layout.keySet()) { - if (node.key.equals("text")) + if (node.key.equals("text")) { - Point point = layout.get(node); + boolean isLink = node.get("href") != null; + if (isLink) g.setColor(LINK_COLOUR); + Point point = layout.get(node); g.drawString(node.value, point.x, point.y); + if (isLink) g.setColor(PLAIN_COLOUR); } else if (node.key.equals("emoji")) { - Point point = layout.get(node); + Point point = layout.get(node); Image image = emojis.get(node.value); if (image != null) { @@ -242,26 +334,192 @@ implements ComponentListener { } } + public void + mousePressed(MouseEvent eM) + { + selStart = identifyNodeAt(eM.getX(), eM.getY()); + selEnd = null; + repaint(); + } + + public void + mouseDragged(MouseEvent eM) + { + if (selStart == null) return; + selEnd = identifyNodeAt(eM.getX(), eM.getY()); + if (selEnd == null) selEnd = layoutEnd; + repaint(); + } + + private Tree + identifyNodeAt(int x, int y) + { + FontMetrics fm = getFontMetrics(getFont()); + int initial = fm.getAscent(); + int advance = fm.getAscent() + fm.getDescent(); + y = snap(y, initial, advance); + + Tree returnee = null; + + int maxX = 0; + for (Tree node: layout.keySet()) + { + Point point = layout.get(node); + assert point != null; + + if (point.y != y) continue; + if (point.x > x) continue; + if (point.x >= maxX) + { + maxX = point.x; + returnee = node; + } + } + + return returnee; + } + + public void + keyPressed(KeyEvent eK) + { + if (!eK.isControlDown()) return; + switch (eK.getKeyCode()) + { + case KeyEvent.VK_C: + ClipboardApi.serve(getSelectedText()); + break; + case KeyEvent.VK_A: + selStart = identifyNodeAt(0, 0); + selEnd = layoutEnd; + repaint(); + break; + } + } + + private String + getSelectedText() + { + assert selStart != null && selEnd != null; + + Point ssp = layout.get(selStart); + Point sep = layout.get(selEnd); + assert ssp != null && sep != null; + boolean flip = ssp.y > sep.y; + flip |= sep.y == ssp.y && sep.x < ssp.x; + if (flip) + { + Point temp = ssp; + ssp = sep; + sep = ssp; + } + + List> selected = new ArrayList<>(); + List points = new ArrayList<>(); + for (Tree node: layout.keySet()) + { + Point point = layout.get(node); + assert point != null; + + boolean c1 = point.y == ssp.y && point.x >= ssp.x; + boolean c2 = point.y == sep.y && point.x < sep.x; + boolean c3 = point.y > ssp.y && point.y < sep.y; + if (!(c1 || c2 || c3)) continue; + + // Just throw them in a pile for now.. + selected.add(node); + points.add(point); + } + + // Now sort them into reading order. + Tree n1, n2; + Point p1, p2; + for (int eo = 1; eo < points.size(); ++eo) + for (int o = points.size() - 1; o >= eo; --o) + { + n1 = selected.get(o - 1); n2 = selected.get(o); + p1 = points.get(o - 1); p2 = points.get(o); + + boolean c1 = p2.y < p1.y; + boolean c2 = p2.y == p1.y && p2.x < p1.x; + if (!(c1 || c2)) continue; + + selected.set(o - 1, n2); + selected.set(o, n1); + points.set(o - 1, p2); + points.set(o, p1); + } + + StringBuilder b = new StringBuilder(); + for (Tree node: selected) + { + boolean t = node.key.equals("text"); + boolean e = node.key.equals("emoji"); + boolean s = node.key.equals("space"); + assert t || e || s; + b.append(node.value); // Same behaviour for all. + } + return b.toString(); + } + + public void + keyReleased(KeyEvent eK) { } + + public void + keyTyped(KeyEvent eK) { } + + public void + mouseReleased(MouseEvent eM) { } + + public void + mouseClicked(MouseEvent eM) { } + + public void + mouseMoved(MouseEvent eM) { } + + public void + mouseEntered(MouseEvent eM) { } + + public void + mouseExited(MouseEvent eM) { } + public void componentResized(ComponentEvent eC) { setText(html); } public void componentMoved(ComponentEvent eC) { } - + public void componentShown(ComponentEvent eC) { } - + public void componentHidden(ComponentEvent eC) { } +// - -%- - + + private static int + snap(int value, int initial, int advance) + { + int offset = value - initial; + if (offset <= 0) offset = 0; + else { + int lines = 1 + ((offset - 1) / advance); + offset = advance * lines; + } + return initial + offset; + } + // ---%-@-%--- RichTextPane3() { layout = new HashMap<>(); + layoutEnd = new Tree<>("text", ""); emojis = new HashMap<>(); setText(new Tree()); this.addComponentListener(this); + this.addMouseListener(this); + this.addMouseMotionListener(this); + this.addKeyListener(this); } } diff --git a/WindowUpdater.java b/WindowUpdater.java index 3fbb4f4..3125e1a 100644 --- a/WindowUpdater.java +++ b/WindowUpdater.java @@ -279,7 +279,8 @@ WindowUpdater { void loadNotificationSound() { - URL url = getClass().getResource("KDE_Dialog_Appear.wav"); + //URL url = getClass().getResource("KDE_Dialog_Appear.wav"); + URL url = getClass().getResource("LinkinPark.wav"); try { Clip clip = AudioSystem.getClip(); clip.open(AudioSystem.getAudioInputStream(url)); diff --git a/graphics/Federated.xcf b/graphics/Federated.xcf old mode 100755 new mode 100644 diff --git a/graphics/Flags.xcf b/graphics/Flags.xcf old mode 100755 new mode 100644 diff --git a/graphics/Hourglass.xcf b/graphics/Hourglass.xcf old mode 100755 new mode 100644 diff --git a/graphics/boostToggled.png b/graphics/boostToggled.png old mode 100755 new mode 100644 diff --git a/graphics/boostUntoggled.png b/graphics/boostUntoggled.png old mode 100755 new mode 100644 diff --git a/graphics/button.png b/graphics/button.png old mode 100755 new mode 100644 diff --git a/graphics/disabledOverlay.png b/graphics/disabledOverlay.png old mode 100755 new mode 100644 diff --git a/graphics/favouriteToggled.png b/graphics/favouriteToggled.png old mode 100755 new mode 100644 diff --git a/graphics/favouriteUntoggled.png b/graphics/favouriteUntoggled.png old mode 100755 new mode 100644 diff --git a/graphics/federated.png b/graphics/federated.png old mode 100755 new mode 100644 diff --git a/graphics/miscToggled.png b/graphics/miscToggled.png old mode 100755 new mode 100644 diff --git a/graphics/miscUntoggled.png b/graphics/miscUntoggled.png old mode 100755 new mode 100644 diff --git a/graphics/ref1.png b/graphics/ref1.png old mode 100755 new mode 100644 diff --git a/graphics/replyToggled.png b/graphics/replyToggled.png old mode 100755 new mode 100644 diff --git a/graphics/replyUntoggled.png b/graphics/replyUntoggled.png old mode 100755 new mode 100644 diff --git a/graphics/selectedOverlay.png b/graphics/selectedOverlay.png old mode 100755 new mode 100644 diff --git a/graphics/test1.png b/graphics/test1.png old mode 100755 new mode 100644 diff --git a/graphics/test2.png b/graphics/test2.png old mode 100755 new mode 100644 diff --git a/graphics/test3.png b/graphics/test3.png old mode 100755 new mode 100644 diff --git a/graphics/test4.png b/graphics/test4.png old mode 100755 new mode 100644