001package polaris.sdk.ui.controls; 002import polaris.sdk.ui.*; 003 004import java.awt.Toolkit; 005import java.awt.datatransfer.*; 006import java.awt.event.KeyEvent; 007import java.util.*; 008import java.util.function.Consumer; 009/** Single-line text editing, UTF-16-safe caret, selection, clipboard, undo/redo, keyboard navigation. */ 010public class TextField extends Widget { 011 protected int caret,anchor,maxLength=4096,scrollLine;protected float scrollX; 012 protected boolean multiline,password,readOnly,selecting;protected String placeholder=""; 013 private double caretActivity; 014 protected Painter metrics;protected Consumer<String> submitted; 015 private final Deque<Edit> undo=new ArrayDeque<Edit>(),redo=new ArrayDeque<Edit>(); 016 private static final class Edit{String value;int caret,anchor;Edit(String s,int c,int a){value=s;caret=c;anchor=a;}} 017 public TextField(){this("");}public TextField(String value){super(value);caret=anchor=text.length();focusable=true;w=240;h=38;} 018 public TextField value(String value){text=value==null?"":value;caret=anchor=text.length();scrollX=0;return this;} 019 @Override public TextField text(String s){return value(s);}public String value(){return text;} 020 public TextField placeholder(String s){placeholder=s==null?"":s;return this;}public TextField readOnly(boolean b){readOnly=b;return this;} 021 public TextField maxLength(int length){if(length<1)throw new IllegalArgumentException("maxLength");maxLength=length;return this;} 022 public TextField onSubmit(Consumer<String> c){submitted=c;return this;} 023 public int caret(){return caret;}public int selectionStart(){return Math.min(anchor,caret);}public int selectionEnd(){return Math.max(anchor,caret);} 024 public String selectedText(){return password?"":text.substring(selectionStart(),selectionEnd());} 025 public void selectAll(){anchor=0;caret=text.length();}public void select(int from,int to){anchor=boundary(from);caret=boundary(to);} 026 private int boundary(int i){i=Math.max(0,Math.min(text.length(),i));if(i>0&&i<text.length()&&Character.isLowSurrogate(text.charAt(i))&&Character.isHighSurrogate(text.charAt(i-1)))i--;return i;} 027 protected boolean accepts(String candidate){return true;} 028 protected void submit(){if(submitted!=null)submitted.accept(text);} 029 protected String display(String raw){if(!password)return raw;StringBuilder b=new StringBuilder();for(int i=0;i<raw.codePointCount(0,raw.length());i++)b.append('\u2022');return b.toString();} 030 protected float measure(String s){return metrics==null?display(s).codePointCount(0,display(s).length())*7:metrics.textWidth(display(s),14);} 031 private void remember(){undo.push(new Edit(text,caret,anchor));while(undo.size()>100)undo.removeLast();redo.clear();} 032 protected void replaceSelection(String insert){if(readOnly)return;StringBuilder clean=new StringBuilder();insert.codePoints().forEach(cp->{if(cp=='\n'&&multiline||cp>=32&&cp!=127)clean.appendCodePoint(cp);});int a=selectionStart(),b=selectionEnd();String candidate=text.substring(0,a)+clean+text.substring(b);if(candidate.codePointCount(0,candidate.length())>maxLength||!accepts(candidate))return;remember();text=candidate;caret=anchor=a+clean.length();ensureCaret();changed();} 033 private void undo(boolean forward){Deque<Edit> from=forward?redo:undo,to=forward?undo:redo;if(from.isEmpty()||readOnly)return;to.push(new Edit(text,caret,anchor));Edit e=from.pop();text=e.value;caret=e.caret;anchor=e.anchor;ensureCaret();changed();} 034 private int previous(int i){return i<=0?0:text.offsetByCodePoints(i,-1);}private int next(int i){return i>=text.length()?text.length():text.offsetByCodePoints(i,1);} 035 private int word(int pos,int dir){int i=pos;if(dir<0){while(i>0&&Character.isWhitespace(text.codePointBefore(i)))i=previous(i);while(i>0&&!Character.isWhitespace(text.codePointBefore(i)))i=previous(i);}else{while(i<text.length()&&!Character.isWhitespace(text.codePointAt(i)))i=next(i);while(i<text.length()&&Character.isWhitespace(text.codePointAt(i)))i=next(i);}return i;} 036 private void move(int p,boolean shift){caret=boundary(p);if(!shift)anchor=caret;ensureCaret();} 037 protected float textInsetLeft(){return 10;}protected float textInsetRight(){return 10;} 038 protected float textTop(){return multiline?10:(h-14)/2;} 039 protected float textOriginX(){return ax+textInsetLeft()-scrollX;} 040 protected boolean showCaret(){return focused();} 041 protected Rect textClip(){return new Rect(ax+textInsetLeft()-3,ay+5,Math.max(0,w-textInsetLeft()-textInsetRight()+6),Math.max(0,h-10)).intersect(clip);} 042 protected void ensureCaret(){int start=multiline?text.lastIndexOf('\n',Math.max(-1,caret-1))+1:0;float end=measure(text.substring(start,caret));float available=Math.max(0,w-textInsetLeft()-textInsetRight()-8);if(end-scrollX>available)scrollX=end-available;if(end<scrollX)scrollX=Math.max(0,end-8);if(multiline){int line=0;for(int i=0;i<caret;i++)if(text.charAt(i)=='\n')line++;int rows=Math.max(1,(int)((h-16)/21));if(line<scrollLine)scrollLine=line;if(line>=scrollLine+rows)scrollLine=line-rows+1;}} 043 protected int position(float px,float py){int line=multiline?Math.max(0,(int)((py-ay-textTop())/21)+scrollLine):0;String[] lines=text.split("\\n",-1);line=Math.min(line,lines.length-1);int base=0;for(int i=0;i<line;i++)base+=lines[i].length()+1;String s=lines[line];float target=px-textOriginX();int i=0;float prev=0;while(i<s.length()){int j=s.offsetByCodePoints(i,1);float width=measure(s.substring(0,j));if(target<(prev+width)/2)return base+i;prev=width;i=j;}return base+s.length();} 044 @Override public void event(UiEvent e){if(e.type==UiEvent.Type.KEY||e.type==UiEvent.Type.TEXT||e.type==UiEvent.Type.DOWN)caretActivity=clock(); 045 if(e.type==UiEvent.Type.DOWN&&e.button==1){int pos=position(e.x,e.y);if(!e.shift())anchor=pos;caret=pos;selecting=true;if(e.clicks>=2){anchor=word(pos, -1);caret=word(pos,1);}e.handled=true;} 046 else if(e.type==UiEvent.Type.MOVE&&selecting){caret=position(e.x,e.y);ensureCaret();e.handled=true;} 047 else if(e.type==UiEvent.Type.UP){selecting=false;} 048 else if(e.type==UiEvent.Type.BLUR){selecting=false;} 049 else if(e.type==UiEvent.Type.WHEEL&&multiline){int count=text.split("\\n",-1).length;scrollLine=Math.max(0,Math.min(Math.max(0,count-1),scrollLine+(int)(e.wheel*3)));e.handled=true;} 050 else if(e.type==UiEvent.Type.TEXT){replaceSelection(e.text);e.handled=true;} 051 else if(e.type==UiEvent.Type.KEY){ 052 if(e.ctrl()){switch(e.key){case KeyEvent.VK_A:selectAll();e.handled=true;return;case KeyEvent.VK_C:case KeyEvent.VK_X:if(!password){try{Toolkit.getDefaultToolkit().getSystemClipboard().setContents(new StringSelection(selectedText()),null);if(e.key==KeyEvent.VK_X)replaceSelection("");}catch(Exception ignored){}}e.handled=true;return;case KeyEvent.VK_V:try{Object s=Toolkit.getDefaultToolkit().getSystemClipboard().getData(DataFlavor.stringFlavor);if(s!=null)replaceSelection(s.toString());}catch(Exception ignored){}e.handled=true;return;case KeyEvent.VK_Z:undo(e.shift());e.handled=true;return;case KeyEvent.VK_Y:undo(true);e.handled=true;return;default:break;}} 053 switch(e.key){ 054 case KeyEvent.VK_LEFT:move(!e.shift()&&anchor!=caret?selectionStart():e.ctrl()?word(caret,-1):previous(caret),e.shift());break; 055 case KeyEvent.VK_RIGHT:move(!e.shift()&&anchor!=caret?selectionEnd():e.ctrl()?word(caret,1):next(caret),e.shift());break; 056 case KeyEvent.VK_HOME:move(e.ctrl()?0:multiline?text.lastIndexOf('\n',Math.max(-1,caret-1))+1:0,e.shift());break; 057 case KeyEvent.VK_END:int nl=text.indexOf('\n',caret);move(e.ctrl()?text.length():multiline&&nl>=0?nl:text.length(),e.shift());break; 058 case KeyEvent.VK_BACK_SPACE:if(anchor==caret)anchor=e.ctrl()?word(caret,-1):previous(caret);replaceSelection("");break; 059 case KeyEvent.VK_DELETE:if(anchor==caret)anchor=e.ctrl()?word(caret,1):next(caret);replaceSelection("");break; 060 case KeyEvent.VK_ENTER:if(multiline&&!e.ctrl())replaceSelection("\n");else submit();break; 061 case KeyEvent.VK_UP:case KeyEvent.VK_DOWN:if(!multiline)return;int start=text.lastIndexOf('\n',Math.max(-1,caret-1))+1,col=caret-start;if(e.key==KeyEvent.VK_UP&&start>0){int prevStart=text.lastIndexOf('\n',start-2)+1;move(Math.min(start-1,prevStart+col),e.shift());}else if(e.key==KeyEvent.VK_DOWN){int end=text.indexOf('\n',caret);if(end>=0){int nextEnd=text.indexOf('\n',end+1);if(nextEnd<0)nextEnd=text.length();move(Math.min(nextEnd,end+1+col),e.shift());}}break; 062 default:return;} 063 e.handled=true; 064 } 065 } 066 @Override protected void paint(Painter p){metrics=p;surface(p);paintText(p);} 067 protected void paintText(Painter p){Rect inner=textClip();p.clip(inner);float yy=ay+textTop(),origin=textOriginX();String[] lines=text.split("\\n",-1);int base=0;int selA=selectionStart(),selB=selectionEnd(); 068 if(text.isEmpty()&&!placeholder.isEmpty())p.text(placeholder,origin,yy,14,theme().muted); 069 for(int line=0;line<lines.length;line++){String s=lines[line];float ly=yy+(line-scrollLine)*21; 070 if(ly>ay+h)break; 071 if(line>=scrollLine){int a=Math.max(base,selA),b=Math.min(base+s.length(),selB);if(a<b){float left=measure(s.substring(0,a-base)),right=measure(s.substring(0,b-base));p.fill(origin+left,ly-2,right-left,20,2,theme().selected);}p.text(display(s),origin,ly,14,ink());if(showCaret()&&caret>=base&&caret<=base+s.length()){float cx=origin+scrollX+motion("caretX",measure(s.substring(0,caret-base))-scrollX,52,.99f);float opacity=clock()-caretActivity<.85?1:(float)(.15+.85*(.5+.5*Math.cos((clock()-caretActivity-.85)*Math.PI*2)));p.line(cx,ly-1,cx,ly+17,1.4f,Theme.alpha(theme().text,opacity));if(ui!=null&&!ui.composition.isEmpty()){p.text(ui.composition,cx,ly,14,theme().accent);p.line(cx,ly+19,cx+p.textWidth(ui.composition,14),ly+19,1,theme().accent);}}} 072 base+=s.length()+1; 073 }p.clip(clip); 074 } 075}