1// © 2016 and later: Unicode, Inc. and others.
2// License & terms of use: http://www.unicode.org/copyright.html#License
3/*
4 *******************************************************************************
5 * Copyright (C) 2004-2011, International Business Machines Corporation and    *
6 * others. All Rights Reserved.                                                *
7 *******************************************************************************
8 */
9
10package com.ibm.icu.dev.tool.ime.translit;
11
12import java.awt.AWTEvent;
13import java.awt.Color;
14import java.awt.Component;
15import java.awt.Dimension;
16import java.awt.Point;
17import java.awt.Rectangle;
18import java.awt.Toolkit;
19import java.awt.Window;
20import java.awt.event.ActionEvent;
21import java.awt.event.ActionListener;
22import java.awt.event.InputEvent;
23import java.awt.event.InputMethodEvent;
24import java.awt.event.KeyEvent;
25import java.awt.event.MouseEvent;
26import java.awt.font.TextAttribute;
27import java.awt.font.TextHitInfo;
28import java.awt.im.InputMethodHighlight;
29import java.awt.im.spi.InputMethod;
30import java.awt.im.spi.InputMethodContext;
31import java.text.AttributedString;
32import java.text.Collator;
33import java.util.Comparator;
34import java.util.Enumeration;
35import java.util.Locale;
36import java.util.MissingResourceException;
37import java.util.ResourceBundle;
38import java.util.TreeSet;
39
40import javax.swing.JComboBox;
41import javax.swing.JLabel;
42import javax.swing.JList;
43import javax.swing.ListCellRenderer;
44
45import com.ibm.icu.impl.Utility;
46import com.ibm.icu.lang.UCharacter;
47import com.ibm.icu.text.ReplaceableString;
48import com.ibm.icu.text.Transliterator;
49
50public class TransliteratorInputMethod implements InputMethod {
51
52    private static boolean usesAttachedIME() {
53        // we're in the ext directory so permissions are not an issue
54        String os = System.getProperty("os.name");
55        if (os != null) {
56            return os.indexOf("Windows") == -1;
57        }
58        return false;
59    }
60
61    // true if Solaris style; false if PC style, assume Apple uses PC style for now
62    private static final boolean attachedStatusWindow = usesAttachedIME();
63
64    // the shared status window
65    private static Window statusWindow;
66
67    // current or last owner
68    private static TransliteratorInputMethod statusWindowOwner;
69
70    // cache location limits for attached
71    private static Rectangle attachedLimits;
72
73    // convenience of access, to reflect the current state
74    private static JComboBox choices;
75
76    //
77    // per-instance state
78    //
79
80    // if we're attached, the status window follows the client window
81    private Point attachedLocation;
82
83    private static int gid;
84
85    private int id = gid++;
86
87    InputMethodContext imc;
88    private boolean enabled = true;
89
90    private int selectedIndex = -1; // index in JComboBox corresponding to our transliterator
91    private Transliterator transliterator;
92    private int desiredContext;
93    private StringBuffer buffer;
94    private ReplaceableString replaceableText;
95    private Transliterator.Position index;
96
97    // debugging
98    private static boolean TRACE_EVENT = false;
99    private static boolean TRACE_MESSAGES = false;
100    private static boolean TRACE_BUFFER = false;
101
102    public TransliteratorInputMethod() {
103        if (TRACE_MESSAGES)
104            dumpStatus("<constructor>");
105
106        buffer = new StringBuffer();
107        replaceableText = new ReplaceableString(buffer);
108        index = new Transliterator.Position();
109    }
110
111    public void dumpStatus(String msg) {
112        System.out.println("(" + this + ") " + msg);
113    }
114
115    public void setInputMethodContext(InputMethodContext context) {
116        initStatusWindow(context);
117
118        imc = context;
119        imc.enableClientWindowNotification(this, attachedStatusWindow);
120    }
121
122    private static void initStatusWindow(InputMethodContext context) {
123        if (statusWindow == null) {
124            String title;
125            try {
126                ResourceBundle rb = ResourceBundle
127                        .getBundle("com.ibm.icu.dev.tool.ime.translit.Transliterator");
128                title = rb.getString("title");
129            } catch (MissingResourceException m) {
130                System.out.println("Transliterator resources missing: " + m);
131                title = "Transliterator Input Method";
132            }
133
134            Window sw = context.createInputMethodWindow(title, false);
135
136            // get all the ICU Transliterators
137            Enumeration en = Transliterator.getAvailableIDs();
138            TreeSet types = new TreeSet(new LabelComparator());
139
140            while (en.hasMoreElements()) {
141                String id = (String) en.nextElement();
142                String name = Transliterator.getDisplayName(id);
143                JLabel label = new JLabel(name);
144                label.setName(id);
145                types.add(label);
146            }
147
148            // add the transliterators to the combo box
149
150            choices = new JComboBox(types.toArray());
151
152            choices.setEditable(false);
153            choices.setSelectedIndex(0);
154            choices.setRenderer(new NameRenderer());
155            choices.setActionCommand("transliterator");
156
157            choices.addActionListener(new ActionListener() {
158                public void actionPerformed(ActionEvent e) {
159                    if (statusWindowOwner != null) {
160                        statusWindowOwner.statusWindowAction(e);
161                    }
162                }
163            });
164
165            sw.add(choices);
166            sw.pack();
167
168            Dimension sd = Toolkit.getDefaultToolkit().getScreenSize();
169            Dimension wd = sw.getSize();
170            if (attachedStatusWindow) {
171                attachedLimits = new Rectangle(0, 0, sd.width - wd.width,
172                        sd.height - wd.height);
173            } else {
174                sw.setLocation(sd.width - wd.width, sd.height - wd.height - 25);
175            }
176
177            synchronized (TransliteratorInputMethod.class) {
178                if (statusWindow == null) {
179                    statusWindow = sw;
180                }
181            }
182        }
183    }
184
185    private void statusWindowAction(ActionEvent e) {
186        if (TRACE_MESSAGES)
187            dumpStatus(">>status window action");
188        JComboBox cb = (JComboBox) e.getSource();
189        int si = cb.getSelectedIndex();
190        if (si != selectedIndex) { // otherwise, we don't need to change
191            if (TRACE_MESSAGES)
192                dumpStatus("status window action oldIndex: " + selectedIndex
193                        + " newIndex: " + si);
194
195            selectedIndex = si;
196
197            JLabel item = (JLabel) cb.getSelectedItem();
198
199            // construct the actual transliterator
200            // commit any text that may be present first
201            commitAll();
202
203            transliterator = Transliterator.getInstance(item.getName());
204            desiredContext = transliterator.getMaximumContextLength();
205
206            reset();
207        }
208        if (TRACE_MESSAGES)
209            dumpStatus("<<status window action");
210    }
211
212    // java has no pin to rectangle function?
213    private static void pin(Point p, Rectangle r) {
214        if (p.x < r.x) {
215            p.x = r.x;
216        } else if (p.x > r.x + r.width) {
217            p.x = r.x + r.width;
218        }
219        if (p.y < r.y) {
220            p.y = r.y;
221        } else if (p.y > r.y + r.height) {
222            p.y = r.y + r.height;
223        }
224    }
225
226    public void notifyClientWindowChange(Rectangle location) {
227        if (TRACE_MESSAGES)
228            dumpStatus(">>notify client window change: " + location);
229        synchronized (TransliteratorInputMethod.class) {
230            if (statusWindowOwner == this) {
231                if (location == null) {
232                    statusWindow.setVisible(false);
233                } else {
234                    attachedLocation = new Point(location.x, location.y
235                            + location.height);
236                    pin(attachedLocation, attachedLimits);
237                    statusWindow.setLocation(attachedLocation);
238                    statusWindow.setVisible(true);
239                }
240            }
241        }
242        if (TRACE_MESSAGES)
243            dumpStatus("<<notify client window change: " + location);
244    }
245
246    public void activate() {
247        if (TRACE_MESSAGES)
248            dumpStatus(">>activate");
249
250        synchronized (TransliteratorInputMethod.class) {
251            if (statusWindowOwner != this) {
252                if (TRACE_MESSAGES)
253                    dumpStatus("setStatusWindowOwner from: " + statusWindowOwner + " to: " + this);
254
255                statusWindowOwner = this;
256                // will be null before first change notification
257                if (attachedStatusWindow && attachedLocation != null) {
258                    statusWindow.setLocation(attachedLocation);
259                }
260                choices.setSelectedIndex(selectedIndex == -1 ? choices
261                        .getSelectedIndex() : selectedIndex);
262            }
263
264            choices.setForeground(Color.BLACK);
265            statusWindow.setVisible(true);
266        }
267        if (TRACE_MESSAGES)
268            dumpStatus("<<activate");
269    }
270
271    public void deactivate(boolean isTemporary) {
272        if (TRACE_MESSAGES)
273            dumpStatus(">>deactivate" + (isTemporary ? " (temporary)" : ""));
274        if (!isTemporary) {
275            synchronized (TransliteratorInputMethod.class) {
276                choices.setForeground(Color.LIGHT_GRAY);
277            }
278        }
279        if (TRACE_MESSAGES)
280            dumpStatus("<<deactivate" + (isTemporary ? " (temporary)" : ""));
281    }
282
283    public void hideWindows() {
284        if (TRACE_MESSAGES)
285            dumpStatus(">>hideWindows");
286        synchronized (TransliteratorInputMethod.class) {
287            if (statusWindowOwner == this) {
288                if (TRACE_MESSAGES)
289                    dumpStatus("hiding");
290                statusWindow.setVisible(false);
291            }
292        }
293        if (TRACE_MESSAGES)
294            dumpStatus("<<hideWindows");
295    }
296
297    public boolean setLocale(Locale locale) {
298        return false;
299    }
300
301    public Locale getLocale() {
302        return Locale.getDefault();
303    }
304
305    public void setCharacterSubsets(Character.Subset[] subsets) {
306    }
307
308    public void reconvert() {
309        throw new UnsupportedOperationException();
310    }
311
312    public void removeNotify() {
313        if (TRACE_MESSAGES)
314            dumpStatus("**removeNotify");
315    }
316
317    public void endComposition() {
318        commitAll();
319    }
320
321    public void dispose() {
322        if (TRACE_MESSAGES)
323            dumpStatus("**dispose");
324    }
325
326    public Object getControlObject() {
327        return null;
328    }
329
330    public void setCompositionEnabled(boolean enable) {
331        enabled = enable;
332    }
333
334    public boolean isCompositionEnabled() {
335        return enabled;
336    }
337
338    // debugging
339    private String eventInfo(AWTEvent event) {
340        String info = event.toString();
341        StringBuffer buf = new StringBuffer();
342        int index1 = info.indexOf("[");
343        int index2 = info.indexOf(",", index1);
344        buf.append(info.substring(index1 + 1, index2));
345
346        index1 = info.indexOf("] on ");
347        index2 = info.indexOf("[", index1);
348        if (index2 != -1) {
349            int index3 = info.lastIndexOf(".", index2);
350            if (index3 < index1 + 4) {
351                index3 = index1 + 4;
352            }
353            buf.append(" on ");
354            buf.append(info.substring(index3 + 1, index2));
355        }
356        return buf.toString();
357    }
358
359    public void dispatchEvent(AWTEvent event) {
360        final int MODIFIERS =
361            InputEvent.CTRL_MASK |
362            InputEvent.META_MASK |
363            InputEvent.ALT_MASK |
364            InputEvent.ALT_GRAPH_MASK;
365
366        switch (event.getID()) {
367        case MouseEvent.MOUSE_PRESSED:
368            if (enabled) {
369                if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(event));
370                // we'll get this even if the user is scrolling, can we rely on the component?
371                // commitAll(); // don't allow even clicks within our own edit area
372            }
373            break;
374
375        case KeyEvent.KEY_TYPED: {
376            if (enabled) {
377                KeyEvent ke = (KeyEvent)event;
378                if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(ke));
379                if ((ke.getModifiers() & MODIFIERS) != 0) {
380                    commitAll(); // assume a command, let it go through
381                } else {
382                    if (handleTyped(ke.getKeyChar())) {
383                        ke.consume();
384                    }
385                }
386            }
387        } break;
388
389        case KeyEvent.KEY_PRESSED: {
390            if (enabled) {
391            KeyEvent ke = (KeyEvent)event;
392            if (TRACE_EVENT) System.out.println("TIM: " + eventInfo(ke));
393                if (handlePressed(ke.getKeyCode())) {
394                    ke.consume();
395                }
396            }
397        } break;
398
399        case KeyEvent.KEY_RELEASED: {
400            // this won't autorepeat, which is better for toggle actions
401            KeyEvent ke = (KeyEvent)event;
402            if (ke.getKeyCode() == KeyEvent.VK_SPACE && ke.isControlDown()) {
403                setCompositionEnabled(!enabled);
404            }
405        } break;
406
407        default:
408            break;
409        }
410    }
411
412    /** Wipe clean */
413    private void reset() {
414        buffer.delete(0, buffer.length());
415        index.contextStart = index.contextLimit = index.start = index.limit = 0;
416    }
417
418    // committed}context-composed|composed
419    //          ^       ^        ^
420    //         cc     start    ctxLim
421
422    private void traceBuffer(String msg, int cc, int off) {
423        if (TRACE_BUFFER)
424            System.out.println(Utility.escape(msg + ": '"
425                    + buffer.substring(0, cc) + '}'
426                    + buffer.substring(cc, index.start) + '-'
427                    + buffer.substring(index.start, index.contextLimit) + '|'
428                    + buffer.substring(index.contextLimit) + '\''));
429    }
430
431    private void update(boolean flush) {
432        int len = buffer.length();
433        String text = buffer.toString();
434        AttributedString as = new AttributedString(text);
435
436        int cc, off;
437        if (flush) {
438            off = index.contextLimit - len; // will be negative
439            cc = index.start = index.limit = index.contextLimit = len;
440        } else {
441            cc = index.start > desiredContext ? index.start - desiredContext
442                    : 0;
443            off = index.contextLimit - cc;
444        }
445
446        if (index.start < len) {
447            as.addAttribute(TextAttribute.INPUT_METHOD_HIGHLIGHT,
448                    InputMethodHighlight.SELECTED_RAW_TEXT_HIGHLIGHT,
449                    index.start, len);
450        }
451
452        imc.dispatchInputMethodEvent(
453                InputMethodEvent.INPUT_METHOD_TEXT_CHANGED, as.getIterator(),
454                cc, TextHitInfo.leading(off), null);
455
456        traceBuffer("update", cc, off);
457
458        if (cc > 0) {
459            buffer.delete(0, cc);
460            index.start -= cc;
461            index.limit -= cc;
462            index.contextLimit -= cc;
463        }
464    }
465
466    private void updateCaret() {
467        imc.dispatchInputMethodEvent(InputMethodEvent.CARET_POSITION_CHANGED,
468                null, 0, TextHitInfo.leading(index.contextLimit), null);
469        traceBuffer("updateCaret", 0, index.contextLimit);
470    }
471
472    private void caretToStart() {
473        if (index.contextLimit > index.start) {
474            index.contextLimit = index.limit = index.start;
475            updateCaret();
476        }
477    }
478
479    private void caretToLimit() {
480        if (index.contextLimit < buffer.length()) {
481            index.contextLimit = index.limit = buffer.length();
482            updateCaret();
483        }
484    }
485
486    private boolean caretTowardsStart() {
487        int bufpos = index.contextLimit;
488        if (bufpos > index.start) {
489            --bufpos;
490            if (bufpos > index.start
491                    && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
492                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
493                --bufpos;
494            }
495            index.contextLimit = index.limit = bufpos;
496            updateCaret();
497            return true;
498        }
499        return commitAll();
500    }
501
502    private boolean caretTowardsLimit() {
503        int bufpos = index.contextLimit;
504        if (bufpos < buffer.length()) {
505            ++bufpos;
506            if (bufpos < buffer.length()
507                    && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
508                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
509                ++bufpos;
510            }
511            index.contextLimit = index.limit = bufpos;
512            updateCaret();
513            return true;
514        }
515        return commitAll();
516    }
517
518    private boolean canBackspace() {
519        return index.contextLimit > 0;
520    }
521
522    private boolean backspace() {
523        int bufpos = index.contextLimit;
524        if (bufpos > 0) {
525            int limit = bufpos;
526            --bufpos;
527            if (bufpos > 0 && UCharacter.isLowSurrogate(buffer.charAt(bufpos))
528                    && UCharacter.isHighSurrogate(buffer.charAt(bufpos - 1))) {
529                --bufpos;
530            }
531            if (bufpos < index.start) {
532                index.start = bufpos;
533            }
534            index.contextLimit = index.limit = bufpos;
535            doDelete(bufpos, limit);
536            return true;
537        }
538        return false;
539    }
540
541    private boolean canDelete() {
542        return index.contextLimit < buffer.length();
543    }
544
545    private boolean delete() {
546        int bufpos = index.contextLimit;
547        if (bufpos < buffer.length()) {
548            int limit = bufpos + 1;
549            if (limit < buffer.length()
550                    && UCharacter.isHighSurrogate(buffer.charAt(limit - 1))
551                    && UCharacter.isLowSurrogate(buffer.charAt(limit))) {
552                ++limit;
553            }
554            doDelete(bufpos, limit);
555            return true;
556        }
557        return false;
558    }
559
560    private void doDelete(int start, int limit) {
561        buffer.delete(start, limit);
562        update(false);
563    }
564
565    private boolean commitAll() {
566        if (buffer.length() > 0) {
567            boolean atStart = index.start == index.contextLimit;
568            boolean didConvert = buffer.length() > index.start;
569            index.contextLimit = index.limit = buffer.length();
570            transliterator.finishTransliteration(replaceableText, index);
571            if (atStart) {
572                index.start = index.limit = index.contextLimit = 0;
573            }
574            update(true);
575            return didConvert;
576        }
577        return false;
578    }
579
580    private void clearAll() {
581        int len = buffer.length();
582        if (len > 0) {
583            if (len > index.start) {
584                buffer.delete(index.start, len);
585            }
586            update(true);
587        }
588    }
589
590    private boolean insert(char c) {
591        transliterator.transliterate(replaceableText, index, c);
592        update(false);
593        return true;
594    }
595
596    private boolean editing() {
597        return buffer.length() > 0;
598    }
599
600    /**
601     * The big problem is that from release to release swing changes how it
602     * handles some characters like tab and backspace.  Sometimes it handles
603     * them as keyTyped events, and sometimes it handles them as keyPressed
604     * events.  If you want to allow the event to go through so swing handles
605     * it, you have to allow one or the other to go through.  If you don't want
606     * the event to go through so you can handle it, you have to stop the
607     * event both places.
608     * @return whether the character was handled
609     */
610    private boolean handleTyped(char ch) {
611        if (enabled) {
612            switch (ch) {
613            case '\b': if (editing()) return backspace(); break;
614            case '\t': if (editing()) { return commitAll(); } break;
615            case '\u001b': if (editing()) { clearAll(); return true; } break;
616            case '\u007f': if (editing()) return delete(); break;
617            default: return insert(ch);
618            }
619        }
620        return false;
621    }
622
623    /**
624     * Handle keyPressed events.
625     */
626    private boolean handlePressed(int code) {
627        if (enabled && editing()) {
628            switch (code) {
629            case KeyEvent.VK_PAGE_UP:
630            case KeyEvent.VK_UP:
631            case KeyEvent.VK_KP_UP:
632            case KeyEvent.VK_HOME:
633            caretToStart(); return true;
634            case KeyEvent.VK_PAGE_DOWN:
635            case KeyEvent.VK_DOWN:
636            case KeyEvent.VK_KP_DOWN:
637            case KeyEvent.VK_END:
638            caretToLimit(); return true;
639            case KeyEvent.VK_LEFT:
640            case KeyEvent.VK_KP_LEFT:
641            return caretTowardsStart();
642            case KeyEvent.VK_RIGHT:
643            case KeyEvent.VK_KP_RIGHT:
644            return caretTowardsLimit();
645            case KeyEvent.VK_BACK_SPACE:
646            return canBackspace(); // unfortunately, in 1.5 swing handles this in keyPressed instead of keyTyped
647            case KeyEvent.VK_DELETE:
648            return canDelete(); // this too?
649            case KeyEvent.VK_TAB:
650            case KeyEvent.VK_ENTER:
651            return commitAll(); // so we'll never handle VK_TAB in keyTyped
652
653            case KeyEvent.VK_SHIFT:
654            case KeyEvent.VK_CONTROL:
655            case KeyEvent.VK_ALT:
656            return false; // ignore these unless a key typed event gets generated
657            default:
658            // by default, let editor handle it, and we'll assume that it will tell us
659            // to endComposition if it does anything funky with, e.g., function keys.
660            return false;
661            }
662        }
663        return false;
664    }
665
666    public String toString() {
667        final String[] names = {
668            "alice", "bill", "carrie", "doug", "elena", "frank", "gertie", "howie", "ingrid", "john"
669        };
670
671        if (id < names.length) {
672            return names[id];
673        } else {
674            return names[id] + "-" + (id/names.length);
675        }
676    }
677}
678
679class NameRenderer extends JLabel implements ListCellRenderer {
680
681    /**
682     * For serialization
683     */
684    private static final long serialVersionUID = -210152863798631747L;
685
686    public Component getListCellRendererComponent(
687        JList list,
688        Object value,
689        int index,
690        boolean isSelected,
691        boolean cellHasFocus) {
692
693        String s = ((JLabel)value).getText();
694        setText(s);
695
696        if (isSelected) {
697            setBackground(list.getSelectionBackground());
698            setForeground(list.getSelectionForeground());
699        } else {
700            setBackground(list.getBackground());
701            setForeground(list.getForeground());
702        }
703
704        setEnabled(list.isEnabled());
705        setFont(list.getFont());
706        setOpaque(true);
707        return this;
708    }
709}
710
711class LabelComparator implements Comparator {
712    public int compare(Object obj1, Object obj2) {
713        Collator collator = Collator.getInstance();
714        return collator.compare(((JLabel)obj1).getText(), ((JLabel)obj2).getText());
715    }
716
717    public boolean equals(Object obj1) {
718        return obj1 instanceof LabelComparator;
719    }
720}
721