001package polaris.sdk.ui;
002import java.util.*;
003import java.util.concurrent.*;
004import java.awt.event.KeyEvent;
005import polaris.sdk.ui.controls.*;
006/** Retained UI with focus, pointer capture, popup/modal routing, tooltips and timed notifications. */
007public final class Ui {
008 private final Thread owner=Thread.currentThread();private Theme theme=Theme.dark();private final Panel root=new Panel();
009 private final ConcurrentLinkedQueue<Runnable> tasks=new ConcurrentLinkedQueue<Runnable>();
010 private final List<Widget> overlays=new ArrayList<Widget>();private final List<Notice> notices=new ArrayList<Notice>();
011 private final Map<Widget,Widget> returnFocus=new IdentityHashMap<Widget,Widget>();
012 private boolean keyboardMode;private final Map<Widget,Double> openedAt=new IdentityHashMap<Widget,Double>();private final List<Exit> exits=new ArrayList<Exit>();private static final class Exit{Widget widget;double start;Exit(Widget w,double t){widget=w;start=t;}}
013 private Widget focus,hover,capture;private float width=1200,height=800,mouseX,mouseY;private double time,hoverSince;
014 public Runnable onResize;
015 public String composition=""; public Runnable commandAction;private static final class Notice {String title,body;double until,start;final MotionValue y=new MotionValue();Notice(String t,String b,double u){title=t;body=b;until=u;start=u-4.5;}}
016 public Ui(){root.glass(false);root.attach(this);root.id("root");}
017 private final BackgroundSettings background=new BackgroundSettings();
018 /** Live standalone scene settings; unaffected by replacing the material theme. */
019 public BackgroundSettings background(){return background;}
020 public Panel root(){return root;}public Theme theme(){return theme;}public void theme(Theme t){assertThread();if(t==null)throw new NullPointerException("theme");theme=t;}
021 public Ui setTheme(Theme t){theme(t);return this;}
022 public Ui antialiasing(Antialiasing quality){assertThread();theme.antialiasing(quality);return this;}
023 public boolean keyboardMode(){return keyboardMode;}
024 public float pointerX(){return mouseX;}public float pointerY(){return mouseY;}
025 public float width(){return width;}public float height(){return height;}public double time(){return time;}public Widget focused(){return focus;}
026 public void post(Runnable r){if(r!=null)tasks.add(r);}public void assertThread(){if(Thread.currentThread()!=owner)throw new IllegalStateException("Use Ui.post from other threads");}
027 public void resize(float w,float h){assertThread();boolean changed=width!=w||height!=h;width=Math.max(1,w);height=Math.max(1,h);root.bounds(0,0,width,height);if(changed&&onResize!=null)onResize.run();layout();}
028 public void layout(){Rect screen=new Rect(0,0,width,height);root.layout(0,0,screen);for(Widget p:overlays)p.layout(0,0,screen);}
029 public Widget find(String id){for(int i=overlays.size()-1;i>=0;i--){Widget f=overlays.get(i).find(id);if(f!=null)return f;}return root.find(id);}
030 public void focus(Widget widget){if(focus==widget)return;if(focus!=null){UiEvent blur=new UiEvent(UiEvent.Type.BLUR);focus.visualEvent(blur);focus.event(blur);}focus=widget!=null&&widget.ui()==this&&widget.canFocus()&&activePath(widget)?widget:null;composition="";if(focus!=null){for(Widget p=focus.parent();p!=null;p=p.parent())if(p instanceof ScrollPane)((ScrollPane)p).reveal(focus);}}
031 public void popup(Widget content,float x,float y){assertThread();exits.removeIf(e->e.widget==content);closePopups();returnFocus.put(content,focus);content.attach(this);content.bounds(Math.max(8,Math.min(x,width-content.width()-8)),Math.max(8,Math.min(y,height-content.height()-8)),Math.min(content.width(),width-16),Math.min(content.height(),height-16));overlays.add(content);openedAt.put(content,time);layout();focus(firstFocusable(content));}
032 public void popupKeepFocus(Widget content,float x,float y){Widget old=focus;popup(content,x,y);focus(old);}
033 public void show(Dialog dialog){assertThread();exits.removeIf(e->e.widget==dialog);if(overlays.contains(dialog)){focus(firstFocusable(dialog));return;}returnFocus.put(dialog,focus);dialog.attach(this);float w=Math.min(dialog.width(),width-32),h=Math.min(dialog.height(),height-32);dialog.bounds((width-w)/2,(height-h)/2,w,h);overlays.add(dialog);openedAt.put(dialog,time);layout();focus(firstFocusable(dialog));}
034 public void close(Widget widget){assertThread();if(overlays.remove(widget)){openedAt.remove(widget);if(!theme.reducedMotion)exits.add(new Exit(widget,time));Widget previous=returnFocus.remove(widget);if(descendant(focus,widget))focus=null;if(descendant(capture,widget))capture=null;widget.attach(null);focus(previous);layout();}}
035 public void closePopups(){for(int i=overlays.size()-1;i>=0;i--){Widget w=overlays.get(i);if(!(w instanceof Dialog))close(w);}}
036 public int overlayCount(){return overlays.size();}public Widget topOverlay(){return overlays.isEmpty()?null:overlays.get(overlays.size()-1);}
037 private boolean activePath(Widget w){for(Widget a=w;a!=null;a=a.parent())if(!a.visible()||!a.enabled()||(a!=w&&a instanceof Accordion&&!((Accordion)a).expanded()))return false;return true;}
038 private static boolean descendant(Widget child,Widget ancestor){for(Widget p=child;p!=null;p=p.parent())if(p==ancestor)return true;return false;}
039 private Widget hit(float x,float y){for(int i=overlays.size()-1;i>=0;i--){Widget o=overlays.get(i),r=o.hit(x,y);if(r!=null)return r;if(o instanceof Dialog&&((Dialog)o).modal())return null;}return root.hit(x,y);}
040 public void input(UiEvent e){assertThread();layout();if(e.type==UiEvent.Type.DOWN)keyboardMode=false;else if(e.type==UiEvent.Type.KEY)keyboardMode=true;if(e.type==UiEvent.Type.MOVE||e.type==UiEvent.Type.DOWN||e.type==UiEvent.Type.UP||e.type==UiEvent.Type.WHEEL){mouseX=e.x;mouseY=e.y;}
041  if(e.type==UiEvent.Type.MOVE){Widget next=hit(e.x,e.y);if(next!=hover){if(hover!=null)hover.hover(false);hover=next;if(hover!=null)hover.hover(true);hoverSince=time;}if(capture!=null)capture.event(e);else if(next!=null)next.event(e);return;}
042  if(e.type==UiEvent.Type.DOWN){Widget top=topOverlay();if(top!=null&&!top.rect().contains(e.x,e.y)&&!(top instanceof Dialog)){close(top);capture=null;return;}Widget w=hit(e.x,e.y);
043   if(e.button==3){for(Widget a=w;a!=null;a=a.parent())if(a.contextMenu()!=null){popup(a.contextMenu(),e.x,e.y);return;}}
044   if(e.button==1){capture=w;Widget f=w;while(f!=null&&!f.canFocus())f=f.parent();focus(f);}if(w!=null){w.visualEvent(e);w.event(e);}return;}
045  if(e.type==UiEvent.Type.UP){Widget w=capture;capture=null;if(w!=null){w.visualEvent(e);w.event(e);}return;}
046  if(e.type==UiEvent.Type.WHEEL){for(Widget w=hit(e.x,e.y);w!=null;w=w.parent()){w.event(e);if(e.handled)break;}return;}
047  if(e.type==UiEvent.Type.KEY){
048   if(e.ctrl()&&e.key==KeyEvent.VK_K&&commandAction!=null&&!(focus instanceof KeyBindingField)){commandAction.run();return;}
049   if(e.key==KeyEvent.VK_ESCAPE){Widget top=topOverlay();if(top!=null){close(top);return;}}
050   if(e.key==KeyEvent.VK_TAB){cycleFocus(e.shift());return;}
051  }
052  if(focus!=null&&focus.ui()==this&&activePath(focus)){focus.visualEvent(e);focus.event(e);}
053 }
054 public void clearInput(){if(capture!=null){capture.event(new UiEvent(UiEvent.Type.BLUR));capture.resetPress();}capture=null;composition="";}
055 private Widget firstFocusable(Widget parent){List<Widget> all=new ArrayList<Widget>();gather(parent,all);return all.isEmpty()?null:all.get(0);}
056 private void gather(Widget w,List<Widget> out){if(!w.enabled()||!w.visible())return;if(w.canFocus())out.add(w);if(w instanceof Accordion&&!((Accordion)w).expanded())return;for(Widget c:w.children())gather(c,out);}
057 private void cycleFocus(boolean back){List<Widget> all=new ArrayList<Widget>();Widget scope=topOverlay();gather(scope==null?root:scope,all);if(all.isEmpty()){focus(null);return;}int i=all.indexOf(focus);focus(all.get((i+(back?-1:1)+all.size())%all.size()));}
058 public void notify(String title,String message){assertThread();notices.add(new Notice(title,message,time+4.5));while(notices.size()>4)notices.remove(0);}
059
060 public void render(Painter p,float w,float h,double time){assertThread();p.configure(theme);p.corners(null);this.time=time;Runnable t;while((t=tasks.poll())!=null)t.run();resize(w,h);
061  if(focus!=null&&(focus.ui()!=this||!activePath(focus)))focus(null);
062  root.draw(p);Rect screen=new Rect(0,0,width,height);
063  for(Exit exit:new ArrayList<Exit>(exits)){float a=(float)((time-exit.start)/.16);if(a>=1||theme.reducedMotion){exits.remove(exit);continue;}p.clip(screen);p.backdrop();if(exit.widget instanceof Dialog&&((Dialog)exit.widget).modal())p.fill(0,0,width,height,0,Theme.alpha(theme.shade,1-a));float scale=1-.035f*a;Painter q=new TransformPainter(p,exit.widget.absoluteX()+exit.widget.width()/2,exit.widget.absoluteY()+exit.widget.height()/2,scale,0,6*a,1-a,screen);exit.widget.draw(q);}
064  for(Widget overlay:new ArrayList<Widget>(overlays)){
065   float age=(float)(time-(openedAt.containsKey(overlay)?openedAt.get(overlay):time));float f=theme.reducedMotion?1:MotionValue.clamp(age/.20f);float opacity=1-(float)Math.pow(1-f,3);float settle=theme.reducedMotion?1:(float)(1-Math.exp(-age*17)*Math.cos(age*13));
066   p.clip(screen);p.backdrop();if(overlay instanceof Dialog&&((Dialog)overlay).modal())p.fill(0,0,width,height,0,Theme.alpha(theme.shade,opacity));
067   Painter q=new TransformPainter(p,overlay.absoluteX()+overlay.width()/2,overlay.absoluteY()+Math.min(40,overlay.height()/2),.94f+.06f*settle,0,(overlay instanceof Dialog?14:-8)*(1-settle),opacity,screen);overlay.draw(q);
068  }
069  p.clip(screen);notices.removeIf(n->n.until<=time);int i=0;if(!notices.isEmpty())p.backdrop();
070  for(Notice n:notices){float enter=theme.reducedMotion?1:MotionValue.clamp((float)(time-n.start)/.24f),leave=theme.reducedMotion?1:MotionValue.clamp((float)(n.until-time)/.22f);float a=Math.min(enter,leave),ease=1-(float)Math.pow(1-a,3);float x=width-346+(1-ease)*50,y=n.y.to(height-112-(i++)*88,time,24,.82f,theme.reducedMotion);Painter q=new TransformPainter(p,x+163,y+38,1,0,0,ease,screen);Theme m=theme.copy();m.tint=Math.min(1,m.tint+.12f);q.clip(screen);q.glass(x,y,326,76,18,m,theme.blur);q.textWeight(n.title,x+17,y+15,14,theme.text,600);q.text(n.body,x+17,y+42,12,theme.muted);}
071  if(hover!=null&&overlays.isEmpty()&&!hover.tooltip().isEmpty()&&time-hoverSince>.55){String txt=hover.tooltip();float tw=Math.min(width-20,p.textWidth(txt,12)+24);float x=Math.min(mouseX+12,width-tw-10),y=Math.min(mouseY+24,height-42);float a=theme.reducedMotion?1:MotionValue.clamp((float)(time-hoverSince-.55)/.16f);Painter q=new TransformPainter(p,x+tw/2,y+16,1,0,(1-a)*5,a,screen);Theme m=theme.copy();m.tint+=.14f;q.clip(screen);q.glass(x,y,tw,32,12,m,theme.blur);q.text(txt,x+12,y+10,12,theme.text);}
072 }
073}