001package polaris.sdk.ui.samples;
002
003import polaris.sdk.ui.*;
004import polaris.sdk.ui.controls.*;
005import java.time.LocalTime;
006import java.time.format.DateTimeFormatter;
007import java.util.*;
008import static polaris.sdk.ui.samples.SceneSupport.*;
009
010/** Local multi-conversation messenger. Automatic replies are explicitly simulated. */
011public final class ChatScene extends SceneSupport.AppScene {
012 public static final String[] CONTACTS={"林间工作室","小林","产品讨论组","文件助手"};
013 public static final class Message {
014  public final String text,time;public final boolean own;
015  Message(String text,boolean own){this.text=text;this.own=own;this.time=LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"));}
016 }
017 private final Panel contacts=new Panel(),room=new Panel();private final SearchField search=new SearchField();
018 private final ListBox contactList=new ListBox();private final List<Integer> filtered=new ArrayList<Integer>();
019 private final Avatar avatar=new Avatar("林");private final Label person=title(CONTACTS[0]),presence=caption("本地会话 · 示例消息");
020 private final ScrollPane timeline=new ScrollPane();private final Transcript transcript=new Transcript();
021 private final TextArea composer=new TextArea();private final Button send=new Button("发送").primary(true),quick=new Button("快捷消息");
022 private final IconButton more=new IconButton("…");private final CheckBox autoReply=new CheckBox("本地自动回复");
023 private final Label hint=caption("Ctrl+Enter 发送 · 消息仅保存在当前窗口"),counter=caption("0 / 500");
024 private final List<List<Message>> histories=new ArrayList<List<Message>>();private final String[] drafts=new String[CONTACTS.length];
025 private final List<Reply> replies=new ArrayList<Reply>();private int current;private int scrollTicks;
026 private static final class Reply {final int contact;final double time;Reply(int c,double t){contact=c;time=t;}}
027 public ChatScene(){super("林间消息","即时聊天 / 会话切换、消息气泡、输入框、快捷回复与自动滚动");id("scene.chat");
028  for(int i=0;i<CONTACTS.length;i++){histories.add(new ArrayList<Message>());drafts[i]="";}
029  histories.get(0).add(new Message("我们正在把日常界面拼成可交互示例。",false));
030  histories.get(0).add(new Message("外卖、聊天和客户端设置都用同一套控件。",true));
031  histories.get(0).add(new Message("试着发送一条消息,或者切换左侧的会话。",false));
032  histories.get(1).add(new Message("你好,这里是小林的本地演示会话。",false));
033  histories.get(2).add(new Message("欢迎进入产品讨论组。这里没有联网服务。",false));
034  histories.get(3).add(new Message("可以把待办文字留在这里;关闭程序后不会保留。",false));
035  mount(this,contacts,"chat.contacts");mount(this,room,"chat.room");
036  mount(contacts,search,"chat.search");search.placeholder("搜索会话");search.onChange(v->filter());
037  mount(contacts,contactList,"chat.list");contactList.glass(false).padding(0).rowHeight(54).rowGap(8);
038  contactList.onSelection(i->{if(i>=0&&i<filtered.size())selectContact(filtered.get(i));});
039  mount(contacts,autoReply,"chat.autoReply");autoReply.selected(true);autoReply.onChange(v->{if(!autoReply.selected())replies.clear();});
040  mount(room,avatar,null);mount(room,person,"chat.person");mount(room,presence,"chat.presence");mount(room,more,"chat.more");
041  more.onClick(v->{PopupMenu menu=new PopupMenu();menu.item("查看会话说明",()->notice(this,CONTACTS[current],"本地消息模型,不连接真实账户或聊天服务。"));menu.item("清空当前会话",()->{histories.get(current).clear();refreshMessages();});menu.open(ui,more.absoluteX(),more.absoluteY()+more.height()+4);});
042  mount(room,timeline,"chat.timeline");timeline.glass(false).padding(0).gap(12).layout(Panel.Layout.COLUMN);timeline.add(transcript);
043  mount(room,composer,"chat.composer");composer.placeholder("输入消息…");composer.maxLength(500);composer.onSubmit(s->send());composer.onChange(v->{drafts[current]=composer.value();refreshSend();});
044  mount(room,send,"chat.send");send.onClick(v->send());mount(room,quick,"chat.quick");
045  quick.onClick(v->{PopupMenu p=new PopupMenu();for(String s:new String[]{"收到了,谢谢。","这个效果不错,继续。","我们来测试一下消息滚动。"})p.item(s,()->{composer.value(s);drafts[current]=s;refreshSend();ui.focus(composer);});p.open(ui,quick.absoluteX(),quick.absoluteY()+quick.height()+4);});
046  mount(room,hint,null);mount(room,counter,"chat.counter");filter();refreshMessages();refreshSend();
047 }
048 public int selectedContact(){return current;}
049 public List<Message> messages(int contact){return Collections.unmodifiableList(histories.get(contact));}
050 public void selectContact(int contact){if(contact<0||contact>=CONTACTS.length)throw new IllegalArgumentException("contact");drafts[current]=composer.value();current=contact;person.text(CONTACTS[current]);avatar.text(CONTACTS[current].substring(0,1));composer.value(drafts[current]);refreshMessages();refreshSend();}
051 private void filter(){filtered.clear();List<String> names=new ArrayList<String>();String q=search.value().trim();for(int i=0;i<CONTACTS.length;i++)if(CONTACTS[i].contains(q)){filtered.add(i);names.add(CONTACTS[i]);}contactList.items(names);int active=filtered.indexOf(current);if(active>=0)contactList.select(active);}
052 public void send(){String text=composer.value().trim();if(text.isEmpty()||text.codePointCount(0,text.length())>500)return;
053  histories.get(current).add(new Message(text,true));composer.value("");drafts[current]="";refreshMessages();refreshSend();
054  if(autoReply.selected())replies.add(new Reply(current,clock()+.7));
055 }
056 private void refreshSend(){int count=composer.value().codePointCount(0,composer.value().length());counter.text(count+" / 500");send.enabled(!composer.value().trim().isEmpty());}
057 private void refreshMessages(){transcript.clear();for(Message message:histories.get(current))transcript.add(new Bubble(message));scrollTicks=4;}
058 private void replies(){Iterator<Reply> it=replies.iterator();while(it.hasNext()){Reply r=it.next();if(clock()>=r.time){histories.get(r.contact).add(new Message("收到。这是本地自动回复,用来演示气泡、滚动和状态更新。",false));it.remove();if(current==r.contact)refreshMessages();}}
059  boolean typing=false;for(Reply r:replies)if(r.contact==current)typing=true;presence.text(typing?"正在生成本地回复…":"本地会话 · 示例消息");
060 }
061 @Override public float preferredHeight(float width){return width<760?760:574;}
062 @Override protected void layoutChildren(){header();replies();boolean small=w<760;float cw=small?w:216,rw=small?w:w-cw-GAP;
063  place(contacts,0,76,cw,small?174:498);place(room,small?0:cw+GAP,small?262:76,rw,498);
064  place(search,16,16,cw-32,34);place(contactList,16,62,cw-32,small?62:368);place(autoReply,16,small?136:448,cw-32,30);
065  place(avatar,16,16,42,42);place(person,70,16,rw-138,25);place(presence,70,43,rw-138,21);place(more,rw-50,20,34,34);
066  place(timeline,16,80,rw-32,258);transcript.bounds(0,0,timeline.contentWidth(),transcript.height());
067  place(composer,16,350,rw-32,72);place(quick,16,434,112,36);place(send,rw-112,434,96,36);
068  place(counter,rw-216,445,92,21);place(hint,16,480,rw-32,18);
069  if(scrollTicks>0){timeline.scroll(Float.MAX_VALUE);scrollTicks--;}
070  h=room.y()+room.height();
071 }
072 private static final class Transcript extends Panel {
073  Transcript(){glass(false);padding(0);clipChildren(false);}
074  @Override protected void layoutChildren(){float y=0;for(Widget node:children){Bubble b=(Bubble)node;float width=Math.min(w*.85f,Math.max(180,b.message.text.codePointCount(0,b.message.text.length())*9+32));b.prepare(width);place(b,b.message.own?Math.max(0,w-width):0,y,width,b.height());y+=b.height()+12;}h=Math.max(1,y);}
075 }
076 private static final class Bubble extends Panel {
077  final Message message;final Label body=new Label().size(13),time=caption("");
078  Bubble(Message m){message=m;mount(this,body,null);mount(this,time,null);time.text((m.own?"我":"对方")+"  "+m.time);h=76;}
079  void prepare(float width){String s=wrap(message.text,Math.max(12,(int)((width-32)/7)));body.text(s);h=12+s.split("\n",-1).length*18+26;}
080  @Override protected void layoutChildren(){place(body,16,12,w-32,h-36);place(time,16,h-22,w-32,18);}
081  @Override protected void paint(Painter p){glassPart(p,ax,ay,w,h,theme().radius,message.own?.16f:.035f,theme().accent,message.own?.22f:0,theme().blur);}
082 }
083}