From 8882078743756aee78f895b2683293cef26b95a2 Mon Sep 17 00:00:00 2001 From: "Pedro M. de Echanove Pasquin" Date: Mon, 26 Oct 2026 23:00:33 +0100 Subject: [PATCH] =?UTF-8?q?A=20text=20field=20that=20serves=20as=20an=20ed?= =?UTF-8?q?itor,=20a=20file=20chooser,=20ltk::files=20with=20callbacks,=20?= =?UTF-8?q?Text::fit,=20App::window=5Ftitle,=20and=20a=20stack=20that=20ke?= =?UTF-8?q?eps=20its=20children=20inside=20it=20A=20multiline=20text=20fie?= =?UTF-8?q?ld=20was=20a=20box=20for=20a=20few=20lines:=20it=20wrapped=20it?= =?UTF-8?q?s=20whole=20value=20by=20shaping=20each=20character=20alone=20o?= =?UTF-8?q?n=20every=20frame,=20every=20click=20and=20every=20caret=20moti?= =?UTF-8?q?on,=20so=20a=20file=20of=20some=20size=20made=20it=20crawl;=20i?= =?UTF-8?q?t=20drew=20a=20tab=20as=20the=20font's=20glyph=20for=20it,=20ha?= =?UTF-8?q?d=20no=20scroll=20bar,=20no=20page=20keys=20and=20no=20way=20to?= =?UTF-8?q?=20number=20its=20lines,=20kept=20no=20record=20of=20the=20view?= =?UTF-8?q?=20it=20showed,=20so=20once=20the=20cursor=20had=20left=20the?= =?UTF-8?q?=20first=20screen=20it=20stayed=20on=20the=20last=20row=20and?= =?UTF-8?q?=20the=20arrows=20moved=20the=20text,=20and=20an=20app=20could?= =?UTF-8?q?=20neither=20colour=20its=20text,=20type=20a=20tab=20with=20Tab?= =?UTF-8?q?,=20give=20it=20a=20face=20of=20its=20own,=20ask=20it=20to=20co?= =?UTF-8?q?py,=20cut,=20paste=20or=20select,=20nor=20hear=20where=20its=20?= =?UTF-8?q?cursor=20was.=20Notes,=20the=20Eydos=20text=20editor,=20needs?= =?UTF-8?q?=20all=20of=20it,=20and=20every=20app=20that=20opens=20or=20sav?= =?UTF-8?q?es=20the=20user's=20files=20needed=20a=20dialog=20of=20its=20ow?= =?UTF-8?q?n.=20Speed.=20`Canvas::measure=5Fchar`=20measures=20a=20charact?= =?UTF-8?q?er's=20advance=20once=20per=20font=20and=20size=20and=20keeps?= =?UTF-8?q?=20it=20(`text=5Fshaping::char=5Fadvance=5Fcached`,=20emptied?= =?UTF-8?q?=20with=20the=20shaped=20lines),=20and=20`compute=5Fvisual=5Fli?= =?UTF-8?q?nes`=20keeps=20the=20last=20four=20layouts=20by=20the=20value's?= =?UTF-8?q?=20hash=20and=20length,=20the=20row=20width,=20the=20size=20and?= =?UTF-8?q?=20the=20face,=20so=20a=20value=20is=20wrapped=20again=20only?= =?UTF-8?q?=20when=20one=20of=20them=20changes,=20and=20drawing,=20hit=20t?= =?UTF-8?q?esting=20and=20caret=20motion=20over=20the=20same=20value=20wra?= =?UTF-8?q?p=20it=20once.=20The=20view.=20`TextEdit::draw`=20returns=20the?= =?UTF-8?q?=20view=20it=20showed,=20which=20the=20frame=20keeps=20in=20`te?= =?UTF-8?q?xt=5Fscroll`,=20so=20the=20view=20moves=20only=20when=20the=20c?= =?UTF-8?q?ursor=20leaves=20it,=20and=20by=20the=20least;=20a=20`TextComma?= =?UTF-8?q?nd::Select`=20no=20longer=20resets=20it.=20`Window::shown`=20gi?= =?UTF-8?q?ves=20the=20lines=20as=20the=20last=20frame=20showed=20them,=20?= =?UTF-8?q?wherever=20the=20cursor=20went=20since,=20and=20a=20press,=20th?= =?UTF-8?q?e=20wheel,=20the=20scroll=20bar=20and=20the=20page=20keys=20act?= =?UTF-8?q?=20on=20them:=20a=20press=20that=20gave=20a=20field=20the=20foc?= =?UTF-8?q?us,=20which=20puts=20its=20cursor=20at=20the=20end,=20landed=20?= =?UTF-8?q?where=20the=20view=20would=20have=20gone=20to=20show=20that=20c?= =?UTF-8?q?ursor,=20near=20the=20end=20of=20the=20text,=20instead=20of=20o?= =?UTF-8?q?n=20the=20character=20pressed.=20The=20cursor=20a=20scroll=20ke?= =?UTF-8?q?eps=20is=20taken=20back=20to=20the=20value's=20length=20as=20th?= =?UTF-8?q?e=20view's=20is,=20so=20a=20scroll=20made=20with=20the=20cursor?= =?UTF-8?q?=20at=20its=20end=20sentinel=20holds=20instead=20of=20going=20b?= =?UTF-8?q?ack=20to=20the=20cursor=20at=20once.=20Tabs.=20In=20a=20multili?= =?UTF-8?q?ne=20field=20a=20tab=20reaches=20the=20next=20stop,=20every=20f?= =?UTF-8?q?our=20digits'=20widths,=20as=20four=20monospace=20columns=20are?= =?UTF-8?q?=20(`theme::TAB=5FCOLUMNS`,=20`text=5Fedit/tabs.rs`),=20when=20?= =?UTF-8?q?it=20is=20drawn,=20selected,=20clicked=20and=20crossed=20with?= =?UTF-8?q?=20the=20arrows.=20`TextEdit::tab=5Ftext(=20text=20)`=20has=20T?= =?UTF-8?q?ab=20type=20`text`=20while=20the=20field=20has=20the=20focus=20?= =?UTF-8?q?instead=20of=20moving=20the=20focus=20on;=20Shift+Tab=20still?= =?UTF-8?q?=20moves=20it=20back=20and=20Ctrl+Tab=20still=20reaches=20the?= =?UTF-8?q?=20app.=20The=20scroll=20bar.=20A=20field=20whose=20lines=20do?= =?UTF-8?q?=20not=20all=20fit=20shows=20a=20scroll=20bar=20along=20its=20r?= =?UTF-8?q?ight=20edge=20(`Window::scrollbar`):=20its=20thumb=20is=20held?= =?UTF-8?q?=20and=20dragged,=20or=20a=20press=20on=20its=20track=20brings?= =?UTF-8?q?=20the=20thumb=20there,=20by=20pointer=20or=20touch,=20without?= =?UTF-8?q?=20moving=20the=20cursor=20(`AppData::text=5Fscrollbar=5Fgrab`,?= =?UTF-8?q?=20`handle=5Ftext=5Fscrollbar=5Fpress`,=20`handle=5Ftext=5Fscro?= =?UTF-8?q?llbar=5Fdrag`);=20the=20pointer=20over=20it=20is=20the=20arrow?= =?UTF-8?q?=20rather=20than=20the=20text=20cursor,=20and=20a=20long=20pres?= =?UTF-8?q?s=20that=20holds=20it=20no=20longer=20opens=20the=20Copy=20/=20?= =?UTF-8?q?Cut=20/=20Paste=20menu.=20Page=20Up=20and=20Page=20Down=20move?= =?UTF-8?q?=20the=20cursor=20of=20a=20multiline=20field=20by=20the=20lines?= =?UTF-8?q?=20on=20screen=20but=20one,=20at=20the=20same=20height=20on=20s?= =?UTF-8?q?creen,=20and=20the=20view=20with=20it,=20to=20the=20start=20or?= =?UTF-8?q?=20the=20end=20from=20the=20first=20or=20the=20last=20page=20(`?= =?UTF-8?q?cursor=5Fvisual=5Fpage`,=20`handle=5Fcursor=5Fpage`);=20Shift?= =?UTF-8?q?=20extends=20the=20selection,=20and=20with=20Ctrl,=20or=20in=20?= =?UTF-8?q?any=20other=20widget,=20the=20keys=20still=20reach=20the=20app.?= =?UTF-8?q?=20A=20key=20without=20Ctrl=20that=20types=20no=20text,=20such?= =?UTF-8?q?=20as=20F3,=20was=20dropped=20while=20a=20text=20field=20had=20?= =?UTF-8?q?the=20focus;=20it=20now=20goes=20to=20`App::on=5Fkey=5Fwith=5Fm?= =?UTF-8?q?odifiers`,=20as=20it=20does=20with=20no=20field=20focused.=20Th?= =?UTF-8?q?e=20text=20as=20an=20editor's.=20`TextEdit::highlights(=20Arc>=20)`=20draws=20runs=20of=20a=20multiline=20v?= =?UTF-8?q?alue,=20byte=20ranges=20in=20order,=20in=20colours=20of=20their?= =?UTF-8?q?=20own,=20what=20no=20run=20covers=20in=20the=20field's=20colou?= =?UTF-8?q?r=20and=20a=20run=20off=20a=20character=20boundary=20left=20out?= =?UTF-8?q?.=20`TextEdit::font(=20face=20)`=20lays=20a=20multiline=20field?= =?UTF-8?q?'s=20text=20out=20in=20a=20face=20of=20its=20own=20at=20the=20f?= =?UTF-8?q?ield's=20size:=20wrapping,=20drawing,=20the=20selection,=20the?= =?UTF-8?q?=20cursor,=20clicks,=20tab=20stops=20and=20the=20arrows=20all?= =?UTF-8?q?=20measure=20through=20one=20`TextFont`,=20the=20size=20and=20t?= =?UTF-8?q?he=20face,=20which=20`text=5Finput=5Fgeometry`=20now=20gives=20?= =?UTF-8?q?the=20runtime's=20paths=20where=20it=20gave=20them=20the=20size?= =?UTF-8?q?;=20`Canvas::measure=5Fchar=5Fwith=5Ffont`=20is=20`measure=5Fch?= =?UTF-8?q?ar`=20in=20another=20face.=20`ltk::monospace=5Ffont()`=20finds?= =?UTF-8?q?=20a=20monospace=20face,=20DejaVu=20Sans=20Mono=20from=20the=20?= =?UTF-8?q?app's=20resources,=20which=20a=20page=20and=20an=20APK=20carry,?= =?UTF-8?q?=20else=20the=20first=20of=20the=20system's=20installed,=20load?= =?UTF-8?q?ed=20once.=20`TextEdit::line=5Fnumbers(=20true=20)`=20numbers?= =?UTF-8?q?=20the=20lines=20in=20a=20column=20at=20the=20field's=20left,?= =?UTF-8?q?=20in=20the=20monospace=20face=20when=20there=20is=20one,=20as?= =?UTF-8?q?=20wide=20as=20the=20number=20of=20the=20last=20line=20needs,?= =?UTF-8?q?=20a=20wrapped=20line=20numbered=20on=20its=20first=20row=20and?= =?UTF-8?q?=20the=20cursor's=20in=20the=20text=20colour=20while=20the=20fi?= =?UTF-8?q?eld=20has=20the=20focus;=20the=20text=20starts=20right=20of=20t?= =?UTF-8?q?he=20column=20alike=20when=20drawn=20and=20in=20every=20runtime?= =?UTF-8?q?=20path,=20since=20they=20all=20lay=20it=20out=20in=20the=20rec?= =?UTF-8?q?t=20`gutter::text=5Frect`=20gives.=20`borderless()`=20now=20als?= =?UTF-8?q?o=20drops=20a=20multiline=20field's=20background=20and=20border?= =?UTF-8?q?.=20Talking=20to=20the=20field.=20`App::take=5Ftext=5Fcommand`?= =?UTF-8?q?=20hands=20a=20field,=20by=20its=20`WidgetId`,=20a=20`TextComma?= =?UTF-8?q?nd`=20after=20the=20next=20frame:=20`Copy`,=20`Cut`,=20`Paste`?= =?UTF-8?q?=20and=20`SelectAll`=20through=20the=20paths=20the=20keys=20tak?= =?UTF-8?q?e,=20and=20`Select=20{=20start,=20end=20}`,=20which=20focuses?= =?UTF-8?q?=20the=20field,=20selects=20the=20range=20with=20its=20ends=20t?= =?UTF-8?q?aken=20back=20to=20the=20value=20and=20to=20its=20characters'?= =?UTF-8?q?=20starts,=20and=20scrolls=20to=20it.=20`Reveal=20{=20start,=20?= =?UTF-8?q?end=20}`=20does=20the=20same=20without=20moving=20the=20focus,?= =?UTF-8?q?=20as=20a=20search=20does=20while=20its=20own=20field=20keeps?= =?UTF-8?q?=20the=20keys,=20and=20a=20multiline=20field=20without=20the=20?= =?UTF-8?q?focus=20now=20shows=20its=20selection=20dimmed=20(`theme::selec?= =?UTF-8?q?tion=5Finactive`).=20`TextEdit::on=5Fcursor(=20|at|=20=E2=80=A6?= =?UTF-8?q?=20)`=20sends=20the=20cursor's=20byte=20offset=20once=20it=20is?= =?UTF-8?q?=20placed=20and=20whenever=20it=20moves=20or=20the=20value=20ch?= =?UTF-8?q?anges=20length=20(`report=5Ftext=5Fcursors`,=20after=20every=20?= =?UTF-8?q?frame),=20as=20an=20editor's=20status=20bar=20showing=20the=20l?= =?UTF-8?q?ine=20needs.=20A=20file=20chooser.=20`ltk::FileChooser`=20(`src?= =?UTF-8?q?/file=5Fchooser/`)=20picks=20a=20file=20to=20open=20or=20a=20fo?= =?UTF-8?q?lder=20and=20a=20name=20to=20save=20one=20as,=20over=20`ltk::fi?= =?UTF-8?q?les`,=20alike=20natively=20and=20in=20a=20browser.=20The=20app?= =?UTF-8?q?=20holds=20it,=20puts=20its=20`view()`=20in=20its=20own=20and?= =?UTF-8?q?=20hands=20its=20`FileChooserMsg`s=20to=20`update()`,=20which?= =?UTF-8?q?=20returns=20`Chosen::Path`=20or=20`Chosen::Cancelled`=20once?= =?UTF-8?q?=20the=20user=20chose;=20`wrap`=20makes=20the=20app's=20message?= =?UTF-8?q?=20of=20the=20chooser's,=20so=20its=20listings=20reach=20it=20t?= =?UTF-8?q?hrough=20the=20app's=20`ChannelSender`.=20It=20offers=20the=20h?= =?UTF-8?q?ome=20folder,=20the=20xdg-user-dirs=20folders=20that=20exist,?= =?UTF-8?q?=20the=20root=20and=20the=20folders=20mounted=20in=20a=20browse?= =?UTF-8?q?r,=20with=20a=20press=20there=20to=20mount=20another;=20it=20li?= =?UTF-8?q?sts=20folders=20first,=20the=20hidden=20entries=20left=20out,?= =?UTF-8?q?=20each=20with=20its=20type's=20icon,=20a=20file=20with=20its?= =?UTF-8?q?=20size=20and=20the=20date=20of=20its=20last=20access=20or=20ch?= =?UTF-8?q?ange,=20sorted=20as=20Crate=20sorts,=20by=20name,=20last=20acce?= =?UTF-8?q?ss=20or=20size,=20each=20key=20in=20a=20direction=20of=20its=20?= =?UTF-8?q?own=20(`Sort`,=20`SortKey`,=20`Directions`).=20`dir`=20starts?= =?UTF-8?q?=20elsewhere,=20`filter(=20&[=20"text/*"=20]=20)`=20shows=20onl?= =?UTF-8?q?y=20some=20types=20of=20files,=20`compact(=20true=20)`=20puts?= =?UTF-8?q?=20the=20places=20behind=20a=20button=20for=20a=20phone,=20`sor?= =?UTF-8?q?t`=20starts=20in=20an=20order=20kept=20from=20`current=5Fsort`,?= =?UTF-8?q?=20and=20`take=5Ffocus=5Frequest`=20gives=20the=20field=20to=20?= =?UTF-8?q?focus.=20Saving=20asks=20before=20replacing=20a=20file=20and=20?= =?UTF-8?q?can=20create=20a=20folder;=20its=20errors=20are=20in=20the=20us?= =?UTF-8?q?er's=20words,=20and=20its=20strings=20in=20ltk's=20seven=20loca?= =?UTF-8?q?les.=20`files::mounts()`=20gives=20the=20folders=20mounted=20in?= =?UTF-8?q?=20a=20browser=20as=20the=20desktop=20last=20said,=20which=20th?= =?UTF-8?q?e=20web=20backend=20now=20keeps.=20`docs/widgets.md`=20describe?= =?UTF-8?q?s=20it=20and=20`examples/file=5Fchooser.rs`=20shows=20it,=20in?= =?UTF-8?q?=20`make=20examples`.=20ltk::files::callback.=20The=20operation?= =?UTF-8?q?s=20of=20`ltk::files`=20with=20a=20function=20in=20place=20of?= =?UTF-8?q?=20a=20message=20(`list`,=20`stat`,=20`read`,=20`read=5Frange`,?= =?UTF-8?q?=20`write`,=20`create=5Fdir`,=20`rename`,=20`remove`,=20`remove?= =?UTF-8?q?=5Fall`,=20`copy`,=20`copy=5Fall`,=20`mount`=20and=20`unmount`)?= =?UTF-8?q?,=20for=20code=20with=20no=20run=20loop's=20`ChannelSender`=20t?= =?UTF-8?q?o=20take=20one,=20such=20as=20Prompt's=20session=20daemon,=20wh?= =?UTF-8?q?ich=20in=20a=20browser=20runs=20in=20the=20page=20beside=20the?= =?UTF-8?q?=20window.=20`done`=20is=20called=20on=20ltk's=20worker=20threa?= =?UTF-8?q?d=20natively=20and=20on=20the=20page's=20thread=20in=20a=20brow?= =?UTF-8?q?ser.=20Closing.=20An=20app=20that=20keeps=20its=20window=20open?= =?UTF-8?q?=20in=20`on=5Fclose=5Frequested`,=20to=20ask=20whether=20to=20s?= =?UTF-8?q?ave,=20was=20not=20drawn=20again,=20so=20its=20question=20appea?= =?UTF-8?q?red=20only=20at=20the=20next=20event,=20such=20as=20a=20press.?= =?UTF-8?q?=20Every=20close=20request,=20from=20the=20compositor,=20the=20?= =?UTF-8?q?title=20bar's=20close=20button,=20back=20and=20the=20browser,?= =?UTF-8?q?=20now=20goes=20through=20`AppData::request=5Fclose`,=20which?= =?UTF-8?q?=20draws=20the=20view=20again=20when=20the=20app=20refused=20it?= =?UTF-8?q?.=20`App::window=5Ftitle`.=20The=20main=20window's=20title,=20r?= =?UTF-8?q?ead=20when=20the=20window=20is=20made,=20which=20the=20title=20?= =?UTF-8?q?bar,=20the=20task=20switcher=20and=20the=20dock=20show;=20by=20?= =?UTF-8?q?default=20the=20`app=5Fid`.=20A=20`ShellMode::Window`=20app=20w?= =?UTF-8?q?as=20titled=20"ltk"=20on=20Wayland,=20whatever=20the=20app,=20w?= =?UTF-8?q?ith=20no=20way=20to=20name=20it=20but=20the=20deprecated=20`win?= =?UTF-8?q?dow=5Fconfig`;=20in=20a=20browser=20its=20title=20was=20its=20`?= =?UTF-8?q?app=5Fid`,=20and=20is=20now=20this.=20`Text::fit`=20and=20`Text?= =?UTF-8?q?::valign`.=20`fit()`=20draws=20a=20text=20at=20the=20largest=20?= =?UTF-8?q?size=20up=20to=20its=20own=20at=20which=20it=20fits=20the=20rec?= =?UTF-8?q?t=20the=20layout=20gives=20it,=20on=20one=20line=20within=20its?= =?UTF-8?q?=20width=20and=20height=20or,=20with=20`wrap`,=20all=20its=20li?= =?UTF-8?q?nes=20within=20its=20height,=20so=20a=20text=20whose=20length?= =?UTF-8?q?=20varies,=20such=20as=20a=20calculator's=20display,=20neither?= =?UTF-8?q?=20overflows=20a=20box=20the=20window=20sizes=20nor=20needs=20i?= =?UTF-8?q?ts=20size=20worked=20out=20from=20the=20window=20and=20its=20co?= =?UTF-8?q?ntent;=20its=20preferred=20size=20is=20still=20the=20one=20at?= =?UTF-8?q?=20its=20own=20size.=20`valign(=20VAlign=20)`=20places=20the=20?= =?UTF-8?q?lines=20at=20the=20top,=20the=20centre=20or=20the=20bottom=20of?= =?UTF-8?q?=20the=20rect.=20A=20stack=20keeps=20its=20children=20inside=20?= =?UTF-8?q?it.=20A=20child=20that=20`Stack`=20aligned=20to=20an=20edge=20o?= =?UTF-8?q?r=20the=20centre=20took=20its=20preferred=20size=20even=20when?= =?UTF-8?q?=20that=20was=20larger=20than=20the=20stack,=20so=20one=20align?= =?UTF-8?q?ed=20to=20the=20bottom=20started=20above=20the=20stack's=20top?= =?UTF-8?q?=20and=20one=20aligned=20to=20the=20right=20left=20of=20its=20l?= =?UTF-8?q?eft=20edge,=20drawn=20over=20whatever=20was=20there.=20Each=20c?= =?UTF-8?q?hild's=20preferred=20size=20is=20now=20capped=20by=20the=20stac?= =?UTF-8?q?k's,=20less=20its=20margin,=20before=20it=20is=20aligned;=20chi?= =?UTF-8?q?ldren=20placed=20with=20`push=5Fplaced`=20and=20the=20offsets?= =?UTF-8?q?=20of=20`push=5Ftranslated`=20are=20as=20they=20were.=20Tests?= =?UTF-8?q?=20cover=20Tab=20with=20and=20without=20`tab=5Ftext`,=20the=20t?= =?UTF-8?q?ext=20commands,=20`Reveal`=20leaving=20the=20focus=20alone,=20a?= =?UTF-8?q?=20highlight=20into=20a=20character,=20`on=5Fcursor`=20reportin?= =?UTF-8?q?g=20once,=20a=20refused=20close=20drawing=20again,=20a=20key=20?= =?UTF-8?q?that=20types=20nothing=20reaching=20the=20app=20from=20a=20fiel?= =?UTF-8?q?d,=20tab=20stops,=20a=20click=20on=20a=20tab,=20the=20kept=20la?= =?UTF-8?q?youts,=20the=20scroll=20bar,=20a=20view=20kept=20while=20the=20?= =?UTF-8?q?cursor=20moves=20inside=20it=20and=20after=20a=20scroll=20with?= =?UTF-8?q?=20the=20cursor=20at=20its=20sentinel,=20a=20click=20landing=20?= =?UTF-8?q?on=20the=20view=20shown,=20the=20page=20keys,=20the=20width=20o?= =?UTF-8?q?f=20the=20line=20numbers,=20the=20file=20chooser's=20listing,?= =?UTF-8?q?=20filters,=20names,=20user=20folders,=20sizes,=20sorting=20and?= =?UTF-8?q?=20its=20open,=20save=20and=20replace=20flows,=20`Text::fit`=20?= =?UTF-8?q?and=20a=20stack's=20aligned=20children.=20`CHANGELOG.md`=20and?= =?UTF-8?q?=20`docs/widgets.md`=20describe=20the=20changes.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 16 + Makefile | 1 + docs/widgets.md | 39 ++- examples/file_chooser.rs | 143 ++++++++ locales/de.yaml | 37 ++ locales/en.yaml | 37 ++ locales/es.yaml | 37 ++ locales/fr.yaml | 37 ++ locales/it.yaml | 37 ++ locales/pt.yaml | 37 ++ locales/pt_BR.yaml | 37 ++ src/app.rs | 32 ++ src/draw/layout.rs | 5 +- src/event_loop/app_data.rs | 16 + src/event_loop/context_menu.rs | 4 +- src/event_loop/cursor_shape.rs | 12 +- src/event_loop/drag.rs | 3 +- src/event_loop/setup.rs | 2 + src/event_loop/step.rs | 104 +++++- src/event_loop/tests.rs | 194 +++++++++- src/event_loop/text_editing/cursor.rs | 36 +- src/event_loop/text_editing/ime.rs | 18 +- src/event_loop/text_editing/scroll.rs | 49 ++- src/event_loop/text_editing/selection.rs | 12 +- src/file_chooser/mod.rs | 428 +++++++++++++++++++++++ src/file_chooser/places.rs | 92 +++++ src/file_chooser/sort.rs | 90 +++++ src/file_chooser/tests.rs | 164 +++++++++ src/file_chooser/view.rs | 369 +++++++++++++++++++ src/files/callback.rs | 75 ++++ src/files/mod.rs | 15 + src/input/keyboard/back.rs | 2 +- src/input/keyboard/dispatch.rs | 1 + src/input/keyboard/shortcuts.rs | 11 + src/input/keyboard/text_keys.rs | 22 +- src/input/pointer/motion.rs | 5 +- src/input/pointer/press.rs | 7 +- src/input/touch/mod.rs | 7 +- src/layout/stack.rs | 2 + src/lib.rs | 6 +- src/platform/wayland/handlers.rs | 10 +- src/platform/wayland/run.rs | 4 +- src/platform/web/desktop.rs | 2 +- src/platform/web/run.rs | 3 +- src/render/mod.rs | 22 ++ src/system_fonts.rs | 35 ++ src/text_shaping.rs | 23 ++ src/widget/element.rs | 9 +- src/widget/handlers.rs | 33 +- src/widget/text/mod.rs | 127 +++++-- src/widget/text/tests.rs | 57 +++ src/widget/text_edit/cursor.rs | 77 +++- src/widget/text_edit/draw.rs | 140 ++++++-- src/widget/text_edit/gutter.rs | 42 +++ src/widget/text_edit/hit_test.rs | 32 +- src/widget/text_edit/mod.rs | 148 +++++++- src/widget/text_edit/scroll.rs | 79 ++++- src/widget/text_edit/tabs.rs | 37 ++ src/widget/text_edit/tests.rs | 132 +++++++ src/widget/text_edit/theme.rs | 24 ++ src/widget/text_edit/wrapping.rs | 86 ++++- tests/layout_stack_spacer.rs | 13 + 62 files changed, 3191 insertions(+), 185 deletions(-) create mode 100644 examples/file_chooser.rs create mode 100644 src/file_chooser/mod.rs create mode 100644 src/file_chooser/places.rs create mode 100644 src/file_chooser/sort.rs create mode 100644 src/file_chooser/tests.rs create mode 100644 src/file_chooser/view.rs create mode 100644 src/files/callback.rs create mode 100644 src/widget/text_edit/gutter.rs create mode 100644 src/widget/text_edit/tabs.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3ecdd..91ff5d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Added +- **`App::window_title`** — the main window's title, read when the window is made, which the title bar, the task switcher and the dock show; by default the `app_id`. A `ShellMode::Window` app was titled "ltk" on Wayland, whatever the app, with no way to name it but the deprecated `window_config`; in a browser its title was its `app_id`, and is now this. +- **`Text::fit` and `Text::valign`** — `fit()` draws a text at the largest size up to its own at which it fits the rect the layout gives it: on one line within the rect's width and height, or, with `wrap`, all its lines within its height. A text whose length varies, such as a number, a total or a title, in a box whose size the window sets, so neither overflows the box nor needs its size worked out from the window and the length of its content. Its preferred size is still the one at its own size. `valign( VAlign )` places the lines at the top (the default), the centre or the bottom of a rect taller than they are, as a fitted text's may be. +- **A text field as an editor: `TextEdit::highlights`, `TextEdit::tab_text` and `App::take_text_command`** — `highlights( Arc> )` draws runs of a multiline value, byte ranges in order, in colours of their own, as a syntax highlighter marks keywords, strings and comments; what no run covers keeps the field's colour, and a run that does not fall on character boundaries is left out. `tab_text( text )` has Tab type `text`, such as four spaces, while the field has the focus, instead of moving the focus on; Shift+Tab still moves it back, and Ctrl+Tab still reaches the app. `App::take_text_command` returns once a `WidgetId` and a `TextCommand` for that field, `Copy`, `Cut`, `Paste`, `SelectAll` or `Select { start, end }`, which the runtime carries out after the next frame, focusing the field first: a field's cursor and selection are the runtime's, so an app's Edit menu or its search had no way to copy what was selected or to select what it found. `Select` takes its offsets to character boundaries within the value and scrolls a multiline field to them. `borderless` now also leaves out a multiline field's background and border, as an editor filling its window wants. Tests cover Tab with and without `tab_text`, selecting and copying, offsets inside a character, cutting, selecting all, and a run inside a character. +- **A file chooser: `ltk::FileChooser`** — picks a file to open (`FileChooser::open`), or a folder and a name to save one as (`FileChooser::save`), over `ltk::files`, alike natively and in a browser, so that an app need not make its own. The app holds it, puts `view()` in its own view and hands its `FileChooserMsg`s to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose; `wrap` makes the app's message of the chooser's, so its listings and folders reach it through the app's `ChannelSender`. It shows the home folder, the xdg-user-dirs folders that exist, the root and the folders mounted in a browser, with a press there to mount another; folders come first, then files by name with their size, each with its type's icon, the hidden ones left out. `dir( path )` starts elsewhere, `filter( &[ "text/*" ] )` shows only files of those types, and `compact( true )` puts the places behind a button for a narrow screen. A button of its header sorts the folder as Crate does, by name, last access or size, each key in a direction of its own, folders always first, showing beside each file its size and the date of its last access or change; `sort( Sort )` starts in an order the app kept from `current_sort()`. Saving asks before replacing a file, and can create a folder; `take_focus_request()` gives the field to focus. Its strings are in ltk's locales; `docs/widgets.md` describes it and `examples/file_chooser.rs` shows it. `files::mounts()` gives the folders mounted in a browser as the desktop last said, which the web backend now keeps. +- **`TextCommand::Reveal { start, end }`** — `Select` without moving the focus: the field's bytes `start..end` are selected and a multiline field scrolls to them while the keys stay where they were, as a search needs while its own field keeps them. A multiline field without the focus now shows its selection, dimmed (`theme::selection_inactive`), where it showed none. +- **`TextEdit::font( face )`** — a multiline field's text in a face of its own, such as `ltk::monospace_font()` for an editor of code, at the field's size. Wrapping, drawing, the selection, the cursor, clicks, tab stops and the arrows all measure in it: they lay the text out through one `TextFont`, the size and the face, which `text_input_geometry` gives the runtime's paths as it gave them the size. `Canvas::measure_char_with_font` is `measure_char` in a face other than the canvas's. +- **`ltk::monospace_font()`** — a monospace face for text that lines up in columns: DejaVu Sans Mono from the app's resources, which a page and an APK carry, else the first of the system's monospace faces installed (DejaVu, Hack, Noto Sans Mono; Android's Droid Sans Mono), loaded once; `None` when there is none. +- **`TextEdit::line_numbers( true )`** — a multiline field numbers its lines in a column at its left, in the monospace face when there is one, as wide as the number of the last line needs, a line that wraps numbered on its first row and the cursor's in the text colour while the field has the focus. The text starts right of the column, alike when drawn, clicked, scrolled and moved through with the keys, since they all lay it out in the same rect (`gutter::text_rect`, which `text_input_geometry` gives). +- **A multiline field's tabs, scroll bar and cursor: `TextEdit::on_cursor`** — a tab in a multiline value reaches the next stop, every four digits' widths, as four monospace columns are (`theme::TAB_COLUMNS`), when it is drawn, selected, clicked and crossed with the arrows, where it was drawn as the font's glyph for it, so `tab_text( "\t" )` makes an editor of tabs. A field whose lines do not all fit shows a scroll bar along its right edge: its thumb is held and dragged, or a press on its track brings the thumb there, by pointer or touch, without moving the cursor, and the pointer over it is the arrow rather than the text cursor. Page Up and Page Down move the cursor by the lines on screen but one, at the same height on screen, and the view with it, to the start or the end from the first or the last page; Shift extends the selection, and with Ctrl, or in any other widget, the keys still reach the app. `on_cursor( |at| … )` sends the cursor's byte offset once it is placed and whenever it moves or the value changes length, as an editor's status bar showing the line needs. +- **`ltk::files::callback`** — the operations of `ltk::files` with a function in place of a message (`list`, `stat`, `read`, `read_range`, `write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`), for code with no run loop's `ChannelSender` to take one, such as Prompt's session daemon, which in a browser runs in the page beside the window. `done` is called on ltk's worker thread natively and on the page's thread in a browser. - **The clipboard and the primary selection between a browser desktop's applications** — on a page with forge/web, what an app copies (`publish_clipboard`) goes to forge/web as a `set_selection` request as well as to the browser's clipboard, and the selection the middle button pastes (`publish_primary_selection`, which did nothing in a browser) as `set_primary_selection`. forge/web's `selection` and `primary_selection` events, which every client hears when one sets them and each hears on connecting, become `AppData::clipboard` and `AppData::primary_selection`, as another client's selection does on Wayland, so copying, and selecting and pasting with the middle button, work between the page's applications whether or not the browser lets a page write its clipboard. A `paste` from the browser still brings what other applications copied; a browser has no primary selection a page can reach. - **The user's files, alike natively and in a browser: `ltk::files`** — asynchronous operations on the user's files whose results reach the app as messages through its `ChannelSender`: `list`, `stat`, `read`, `read_range`, `write`, `open_write`, `create_dir`, `rename`, `remove`, `remove_all`, `copy`, `copy_all`, `mount` and `unmount`, each taking the sender and a `done` that turns the `io::Result` into a message. Natively they run `std::fs` on a worker thread of ltk's, and each copy on a thread of its own; in a browser they are forge/web's `fs_*` requests over the app's connection, contents travelling as a `Uint8Array` beside the JSON as the second argument of the connection's `request` and of the client's event handler, and what is asked before the app connects is sent once it has, or fails with `Unsupported` on a page without a desktop. Contents travel in chunks: a large file is read with `read_range` and written through a `Writer`, chunk by chunk, `files::CHUNK` (1 MiB) at a time, and its contents appear only once the writer is closed, a new file showing empty until then in a browser; natively it is written beside the file and renamed over it, and a writer dropped unclosed leaves the file as it was. A copy runs where the files are, reports its progress every 100 ms, never replaces an existing file, and, cancelled through its `Operation` or failed, leaves nothing behind. Errors are the same `io::ErrorKind`s on every platform. `Entry` and `Metadata` give a file's kind, size and times, the access time natively only. `mount` asks the browser for a folder of the user's computer, from the user's press, and fails with `Unsupported` natively. `App::on_files_changed`, `App::on_mounts_changed` and `App::on_open_files` hear of forge/web's `fs_changed`, `fs_mounts` and `open_files`. `files::mime_type( name )` gives a file's type by shared-mime-info's `globs2`, the resource directory's `mime/globs2` first, which `scripts/android-assets.sh`, and so `web-assets.sh`, now copies into the tree with shared-mime-info's `subclasses` (`MIME_DIR` overrides where from), then the system's. `rename` never replaces what is at `to`, natively as in a browser. Tests cover writing, aborting, listing, ranges, removing, copying a tree with its progress, a cancelled copy, a copy that would replace, and the globs. - **`ltk::web::shell_request( json )`** — a shell message that waits for forge/web's reply, such as `get_window_thumbnail`: sent as `shell_send` sends one and answered at once with forge/web's reply as JSON, or `None` before the app has connected, since a request is not kept for later, and when forge/web replies nothing. A shell's requests that wait for an answer natively got none in a browser. @@ -69,6 +79,8 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Changed +- **A key that types nothing reaches the app from a text field** — with a text field focused, a key without Ctrl that types no text, such as F3, was dropped; it now goes to `App::on_key_with_modifiers`, as it does with no field focused, so an editor can search again with F3 and back with Shift+F3. +- **A long multiline value no longer slows every frame** — wrapping measured each character by shaping it alone, on every draw, click and caret motion. Each character's advance is now measured once per font and size (`Canvas::measure_char`), and the last layouts are kept by the value, width and font, so a value is wrapped again only when one of them changes. - **A surface's scale may be fractional.** `SurfaceState::scale_factor` is an `f32`, the physical pixels per logical one, which `Platform::prepare_canvas`, `Presentation::scale` and the paint path take as it is; a surface's physical size is its logical size times the scale, rounded once (`physical_width`, `physical_height`), and `buffer_scale()` gives the whole number Wayland's buffer scale and regions need. Wayland and Android still set whole scales, so nothing changes there; the browser backend sets the page's `devicePixelRatio`. `App::on_scale_changed` keeps its `u32`, the scale rounded, and `App::on_resize` gives the exact physical size. - **`ltk::ChannelSender` is ltk's own type** instead of a re-export of calloop's `Sender`, with the same `send`, returning `Result<(), std::sync::mpsc::SendError>`, and `Clone`, so apps that only send and clone it build unchanged; code that named calloop's type has to name ltk's. The runtime arms its timers and channels through `event_loop/sources.rs` instead of a calloop `LoopHandle`. calloop, khronos-egl and raw-window-handle are dependencies only off `wasm32`, where `egl_context`, `UiSurface::try_new_gles`, `UiSurface::from_current_gles_loader` and the native GL names of `BorrowedGlesTexture` (`texture_id`, `framebuffer_id`) do not exist; the monotonic clock is `crate::time::Instant`, which is `web_time::Instant` on `wasm32`, and `chrono` takes its `wasmbind` feature there. - **Themes, fonts, keyboard layouts and images are read through `vfs.rs`**, which is the file system natively and on `wasm32` a set of files held in memory, filled through `vfs::insert`. A wallpaper or an `Image::from_path` is now decoded from the file's bytes, so its format is told from its contents rather than its extension. @@ -90,6 +102,10 @@ All notable changes to `ltk` are documented here. The format is based on [Keep a ### Fixed +- **A close the app refuses shows what it asks** — an app that keeps its window open in `on_close_requested`, to ask whether to save, was not drawn again, so its question appeared only at the next event, such as a press. Every close request now goes through `AppData::request_close`, which draws the view again when the app refused it. +- **A multiline field keeps the view it shows** — the first line on screen was worked out on every frame from the last one scrolled to by hand, or from the top, and never kept, so once the cursor had gone below the first screen it stayed on the last row: the arrows moved the text instead of the cursor, and a click brought the line clicked to the bottom. `TextEdit::draw` now returns the view it showed, which the frame keeps, so the view moves only when the cursor leaves it, and by the least; a `TextCommand::Select` no longer resets it. A press, the wheel, the scroll bar and the page keys act on the lines on screen as the last frame showed them (`Window::shown`): a press that gave a field the focus, which puts its cursor at the end, landed where the view would have gone to show that cursor, near the end of the text, instead of on the character pressed. A long press that holds the scroll bar no longer opens the Copy / Cut / Paste menu. +- **A multiline field scrolled by hand stays where it was taken** — focusing a field puts its cursor at the end through a sentinel past the value, and a scroll by the wheel kept that sentinel while the view compared it with the cursor taken back to the value's length: they never matched, so the view went back to the cursor at once and the wheel seemed to do nothing. The kept cursor is now taken back the same way. +- **A stack keeps its children inside it.** A child that `Stack` aligned to an edge or the centre took its preferred size even when that was larger than the stack, so one aligned to the bottom started above the stack's top, and one aligned to the right left of its left edge, drawn over whatever was there. Each child's preferred size is now capped by the stack's, less its margin, before it is aligned, so a child larger than the stack gets the stack; children placed with `push_placed` and the offsets of `push_translated` are as they were. - **A pull from the top unwound back to where it started still reports its release.** `ReleaseEvent::OverscrollDown` was sent only while some overscroll was left, so a pull dragged back up to zero ended without one, and an app that had moved its view with the pull's progress was left waiting for it. `GestureState::overscroll_engaged` remembers that the gesture reported progress, and its release now always reports `OverscrollDown`, uncommitted when nothing is left of the pull; a drag that never reached overscroll still reports nothing. - **Pasting what another application copied.** The Wayland backend bound its `wl_data_device` and its primary-selection device in `SeatHandler::new_seat`, which smithay-client-toolkit calls only for seats announced after start-up, not for the seat already there when the app connects. An app therefore never had either device, the compositor never offered it another client's selection, and neither Ctrl+V nor the middle button pasted anything copied outside the app, nor did what it copied reach other applications through the compositor. Both devices are now bound on the seat's first capability, which comes for every seat. - **The middle button pastes the selection just made in another application.** A compositor offers the primary selection to the window it gives the keyboard, and gives it on the press, so a middle click on a window that did not have the keyboard reaches it together with the offer. ltk reads an offer on a thread of its own and pasted at once what it had read before, so after selecting in another application an ltk app pasted the selection before that. A middle-button paste, into the text field under the pointer or one an app asks for with `SelectionKind::Primary`, now waits while a selection is being read (`Platform::primary_selection_pending`, which the Wayland backend counts) and is made once it has been, or after 500 ms with what there is. diff --git a/Makefile b/Makefile index 96b6d23..44e966a 100644 --- a/Makefile +++ b/Makefile @@ -149,6 +149,7 @@ examples: LTK_THEMES_DIR=themes cargo run --release --example carousel LTK_THEMES_DIR=themes cargo run --release --example clip_path LTK_THEMES_DIR=themes cargo run --release --example tab_strip + LTK_THEMES_DIR=themes cargo run --release --example file_chooser clean: dh_clean diff --git a/docs/widgets.md b/docs/widgets.md index e7e46e1..9284f2a 100644 --- a/docs/widgets.md +++ b/docs/widgets.md @@ -26,7 +26,7 @@ patterns built from these widgets, see [`docs/cookbook.md`](./cookbook.md). - [Overlays and feedback](#overlays-and-feedback) - [`spinner`](#spinner) · [`toast`](#toast) · [`tooltip`](#tooltip) · [`anchored`](#anchored) · [`combo`](#combo) · [`tabs`](#tabs) · [`notebook`](#notebook) · [`tab_strip`](#tab_strip) · [`dialog`](#dialog) - [Pickers](#pickers) - - [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker) + - [`date_picker`](#date_picker) · [`time_picker`](#time_picker) · [`color_picker`](#color_picker) · [`file_chooser`](#file_chooser) - [Layouts](#layouts) - [`column`](#column) · [`row`](#row) · [`stack`](#stack) · [`grid`](#grid) · [`spacer`](#spacer) @@ -418,7 +418,7 @@ A text input with cursor, Backspace, Enter / Submit, clipboard support, a `secure( true )` password mode that masks the visible characters and zeroizes the buffer on drop, and a `password_toggle( visible, on_toggle )` builder that pins a show / -hide-password eye icon to the right edge of the field. Single-line by default; `multiline( true )` switches to a multi-row editor whose visible height follows `rows( n )`. A multiline field whose text overflows scrolls with the wheel or a touchpad, three lines per notch, and takes the motion ahead of any `scroll` around it while it can still move that way; once the cursor moves again the view follows it, by the least it has to. +hide-password eye icon to the right edge of the field. Single-line by default; `multiline( true )` switches to a multi-row editor whose visible height follows `rows( n )`. A multiline field whose text overflows scrolls with the wheel or a touchpad, three lines per notch, and takes the motion ahead of any `scroll` around it while it can still move that way; once the cursor moves again the view follows it, by the least it has to. Such a field shows a scroll bar along its right edge when its lines do not all fit; Page Up and Page Down move its cursor a screen at a time. As an editor, `highlights( runs )` colours runs of its text, `tab_text( "\t" )` has Tab type a tab, which reaches the next stop every four spaces, `line_numbers( true )` numbers its lines in a column at its left, and `on_cursor( … )` reports where its cursor is. **When**: login fields, chat inputs, search boxes. @@ -1020,6 +1020,41 @@ color_picker( current ).on_change( Msg::Picked ) # } ``` +### `file_chooser` + +A file to open, or a folder and a name to save one as, over `ltk::files`, alike natively and in a browser. It is not a builder but a component the app holds: `FileChooser::open` or `FileChooser::save` makes it, its `view()` goes in the app's view, and its `FileChooserMsg`s, which `wrap` makes the app's own, go to `update()`, which returns `Chosen::Path` or `Chosen::Cancelled` once the user chose. It lists the folder on its own through the app's `ChannelSender`, shows the home folder, the user's folders and the folders mounted in a browser as places, asks before saving over a file, and can create a folder. `dir` starts elsewhere than the home folder, `filter` shows only some types of files, `compact` puts the places behind a button for a phone, `sort` starts in an order kept from `current_sort` (by name, last access or size, as Crate sorts, which the user changes from the header), and `take_focus_request` gives the field to focus. + +**When**: any app that opens or saves the user's files, instead of a dialog of its own. + +```rust,no_run +# use std::path::PathBuf; +# use ltk::{ ChannelSender, Chosen, Element, FileChooser, FileChooserMsg }; +# #[ derive( Clone ) ] enum Msg { Open, Chooser( FileChooserMsg ) } +# struct App { chooser: Option>, sender: ChannelSender, opened: Option } +# impl App { +fn update( &mut self, msg: Msg ) +{ + match msg + { + Msg::Open => self.chooser = Some( FileChooser::open( &self.sender, Msg::Chooser ).filter( &[ "text/*" ] ) ), + Msg::Chooser( msg ) => + { + let Some( chooser ) = &mut self.chooser else { return }; + match chooser.update( msg ) + { + Some( Chosen::Path( path ) ) => { self.chooser = None; self.opened = Some( path ); } + Some( Chosen::Cancelled ) => self.chooser = None, + None => {} + } + } + } +} +# fn view( &self ) -> Element { self.chooser.as_ref().map( FileChooser::view ).unwrap() } +# } +``` + +**See also**: [`ltk::files`](../src/files/mod.rs) for reading and writing the file chosen, and `examples/file_chooser.rs`. + --- ## Layouts diff --git a/examples/file_chooser.rs b/examples/file_chooser.rs new file mode 100644 index 0000000..c93ff79 --- /dev/null +++ b/examples/file_chooser.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! `cargo run --example file_chooser` +//! +//! Demonstrates `FileChooser`. "Open…" picks a file from the home folder, "Save as…" a folder and a name, offering +//! `untitled.txt` and asking before it replaces a file; the page then shows what was chosen, which nothing reads or +//! writes. "Compact" switches the next chooser to the phone layout, with the places behind a button. The app holds the +//! chooser, puts its view in place of the page, hands it its messages, and passes on the field it wants focused. +//! +//! Esc closes the chooser, then quits. +//! +//! NOTE: ltk is a Wayland layer-shell toolkit. This example needs a running Wayland compositor. + +use ltk::{ + App, ButtonVariant, ChannelSender, Chosen, Element, FileChooser, FileChooserMsg, Keysym, Length, WidgetId, + button, column, container, row, text, +}; + +#[ derive( Clone ) ] +enum Message +{ + Open, + SaveAs, + ToggleCompact, + Chooser( FileChooserMsg ), +} + +struct FileChooserApp +{ + sender: Option>, + chooser: Option>, + compact: bool, + chosen: String, + focus: Option, +} + +impl FileChooserApp +{ + fn new() -> Self + { + Self { sender: None, chooser: None, compact: false, chosen: "Nothing chosen yet".to_string(), focus: None } + } + + fn show( &mut self, chooser: FileChooser ) + { + let mut chooser = chooser.compact( self.compact ); + self.focus = chooser.take_focus_request(); + self.chooser = Some( chooser ); + } + + fn page( &self ) -> Element + { + let compact = if self.compact { "Desktop" } else { "Compact" }; + let buttons = row() + .spacing( Length::widget( 8.0 ) ) + .push( button( "Open…" ).on_press( Message::Open ) ) + .push( button( "Save as…" ).variant( ButtonVariant::Secondary ).on_press( Message::SaveAs ) ) + .push( button( compact ).variant( ButtonVariant::Tertiary ).on_press( Message::ToggleCompact ) ); + let page = column() + .padding( 24.0 ) + .spacing( Length::widget( 16.0 ) ) + .push( buttons ) + .push( text( self.chosen.clone() ).size( 16.0 ).wrap( true ) ); + container( page ).into() + } +} + +impl App for FileChooserApp +{ + type Message = Message; + + fn app_id( &self ) -> &str { "net.liberux.ltk.example.file_chooser" } + fn save_state( &self ) -> Option> { None } + fn restore_state( &mut self, _state: Vec ) {} + + fn set_channel_sender( &mut self, sender: ChannelSender ) + { + self.sender = Some( sender ); + } + + fn view( &self ) -> Element + { + match &self.chooser + { + Some( chooser ) => chooser.view(), + None => self.page(), + } + } + + fn update( &mut self, msg: Message ) + { + match msg + { + Message::Open => + { + let Some( sender ) = self.sender.clone() else { return }; + self.show( FileChooser::open( &sender, Message::Chooser ) ); + } + Message::SaveAs => + { + let Some( sender ) = self.sender.clone() else { return }; + self.show( FileChooser::save( &sender, Message::Chooser, "untitled.txt" ) ); + } + Message::ToggleCompact => self.compact = !self.compact, + Message::Chooser( msg ) => + { + let Some( chooser ) = &mut self.chooser else { return }; + match chooser.update( msg ) + { + Some( Chosen::Path( path ) ) => + { + self.chosen = format!( "Chosen: {}", path.display() ); + self.chooser = None; + } + Some( Chosen::Cancelled ) => + { + self.chosen = "Cancelled".to_string(); + self.chooser = None; + } + None => if let Some( focus ) = chooser.take_focus_request() { self.focus = Some( focus ); }, + } + } + } + } + + fn take_focus_request( &mut self ) -> Option + { + self.focus.take() + } + + fn on_key( &mut self, keysym: Keysym ) -> Option + { + if keysym != Keysym::Escape { return None; } + if self.chooser.is_some() { return Some( Message::Chooser( FileChooserMsg::Cancel ) ); } + std::process::exit( 0 ); + } +} + +fn main() +{ + ltk::run( FileChooserApp::new() ); +} diff --git a/locales/de.yaml b/locales/de.yaml index 784b85e..5b2c013 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Ausschneiden" paste: "Einfügen" delete: "Löschen" + +file_chooser: + open: "Öffnen" + save: "Speichern unter" + places: "Orte" + up: "Nach oben" + new_folder: "Neuer Ordner" + cancel: "Abbrechen" + close: "Schließen" + mount: "Ordner hinzufügen …" + empty: "Dieser Ordner ist leer" + name: "Dateiname" + save_button: "Speichern" + folder_name: "Ordnername" + create: "Erstellen" + replace_question: "„%{name}“ existiert bereits. Ersetzen?" + keep: "Behalten" + replace: "Ersetzen" + home: "Persönlicher Ordner" + desktop: "Schreibtisch" + documents: "Dokumente" + downloads: "Downloads" + music: "Musik" + pictures: "Bilder" + videos: "Videos" + root: "Rechner" + folder: "Ordner" + not_found: "Nicht mehr vorhanden" + denied: "Keine Berechtigung zum Öffnen" + exists: "Es gibt bereits einen mit diesem Namen" + unsupported: "Hier nicht verfügbar" + sort: "Sortieren" + sort_name: "Name" + sort_accessed: "Letzter Zugriff" + sort_size: "Größe" + ascending: "Aufsteigend" + descending: "Absteigend" diff --git a/locales/en.yaml b/locales/en.yaml index 42894f8..fb760bc 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Cut" paste: "Paste" delete: "Delete" + +file_chooser: + open: "Open" + save: "Save as" + places: "Places" + up: "Up" + new_folder: "New folder" + cancel: "Cancel" + close: "Close" + mount: "Add a folder…" + empty: "This folder is empty" + name: "File name" + save_button: "Save" + folder_name: "Folder name" + create: "Create" + replace_question: "“%{name}” already exists. Replace it?" + keep: "Keep" + replace: "Replace" + home: "Home" + desktop: "Desktop" + documents: "Documents" + downloads: "Downloads" + music: "Music" + pictures: "Pictures" + videos: "Videos" + root: "Computer" + folder: "Folder" + not_found: "It is not there any more" + denied: "You are not allowed to open it" + exists: "There is already one with that name" + unsupported: "Not available here" + sort: "Sort" + sort_name: "Name" + sort_accessed: "Last access" + sort_size: "Size" + ascending: "Ascending" + descending: "Descending" diff --git a/locales/es.yaml b/locales/es.yaml index 4a20c63..2f9dd04 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Cortar" paste: "Pegar" delete: "Borrar" + +file_chooser: + open: "Abrir" + save: "Guardar como" + places: "Lugares" + up: "Subir" + new_folder: "Carpeta nueva" + cancel: "Cancelar" + close: "Cerrar" + mount: "Añadir una carpeta…" + empty: "Esta carpeta está vacía" + name: "Nombre del fichero" + save_button: "Guardar" + folder_name: "Nombre de la carpeta" + create: "Crear" + replace_question: "«%{name}» ya existe. ¿Reemplazarlo?" + keep: "Conservar" + replace: "Reemplazar" + home: "Carpeta personal" + desktop: "Escritorio" + documents: "Documentos" + downloads: "Descargas" + music: "Música" + pictures: "Imágenes" + videos: "Vídeos" + root: "Equipo" + folder: "Carpeta" + not_found: "Ya no está" + denied: "No tienes permiso para abrirlo" + exists: "Ya hay uno con ese nombre" + unsupported: "No disponible aquí" + sort: "Ordenar" + sort_name: "Nombre" + sort_accessed: "Último acceso" + sort_size: "Tamaño" + ascending: "Ascendente" + descending: "Descendente" diff --git a/locales/fr.yaml b/locales/fr.yaml index 15f5a25..584d4ee 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Couper" paste: "Coller" delete: "Supprimer" + +file_chooser: + open: "Ouvrir" + save: "Enregistrer sous" + places: "Emplacements" + up: "Remonter" + new_folder: "Nouveau dossier" + cancel: "Annuler" + close: "Fermer" + mount: "Ajouter un dossier…" + empty: "Ce dossier est vide" + name: "Nom du fichier" + save_button: "Enregistrer" + folder_name: "Nom du dossier" + create: "Créer" + replace_question: "« %{name} » existe déjà. Le remplacer ?" + keep: "Conserver" + replace: "Remplacer" + home: "Dossier personnel" + desktop: "Bureau" + documents: "Documents" + downloads: "Téléchargements" + music: "Musique" + pictures: "Images" + videos: "Vidéos" + root: "Ordinateur" + folder: "Dossier" + not_found: "Il n’existe plus" + denied: "Vous n’avez pas le droit de l’ouvrir" + exists: "Il en existe déjà un avec ce nom" + unsupported: "Indisponible ici" + sort: "Trier" + sort_name: "Nom" + sort_accessed: "Dernier accès" + sort_size: "Taille" + ascending: "Croissant" + descending: "Décroissant" diff --git a/locales/it.yaml b/locales/it.yaml index 7e16a09..9ceb576 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Taglia" paste: "Incolla" delete: "Elimina" + +file_chooser: + open: "Apri" + save: "Salva come" + places: "Risorse" + up: "Su" + new_folder: "Nuova cartella" + cancel: "Annulla" + close: "Chiudi" + mount: "Aggiungi una cartella…" + empty: "Questa cartella è vuota" + name: "Nome del file" + save_button: "Salva" + folder_name: "Nome della cartella" + create: "Crea" + replace_question: "«%{name}» esiste già. Sostituirlo?" + keep: "Mantieni" + replace: "Sostituisci" + home: "Cartella home" + desktop: "Scrivania" + documents: "Documenti" + downloads: "Scaricati" + music: "Musica" + pictures: "Immagini" + videos: "Video" + root: "Computer" + folder: "Cartella" + not_found: "Non c’è più" + denied: "Non hai il permesso di aprirlo" + exists: "Ne esiste già uno con questo nome" + unsupported: "Non disponibile qui" + sort: "Ordina" + sort_name: "Nome" + sort_accessed: "Ultimo accesso" + sort_size: "Dimensione" + ascending: "Crescente" + descending: "Decrescente" diff --git a/locales/pt.yaml b/locales/pt.yaml index 99c64fc..0f9529c 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Cortar" paste: "Colar" delete: "Eliminar" + +file_chooser: + open: "Abrir" + save: "Guardar como" + places: "Locais" + up: "Subir" + new_folder: "Nova pasta" + cancel: "Cancelar" + close: "Fechar" + mount: "Adicionar uma pasta…" + empty: "Esta pasta está vazia" + name: "Nome do ficheiro" + save_button: "Guardar" + folder_name: "Nome da pasta" + create: "Criar" + replace_question: "«%{name}» já existe. Substituí-lo?" + keep: "Manter" + replace: "Substituir" + home: "Pasta pessoal" + desktop: "Área de trabalho" + documents: "Documentos" + downloads: "Transferências" + music: "Música" + pictures: "Imagens" + videos: "Vídeos" + root: "Computador" + folder: "Pasta" + not_found: "Já não existe" + denied: "Não tem permissão para o abrir" + exists: "Já existe um com esse nome" + unsupported: "Indisponível aqui" + sort: "Ordenar" + sort_name: "Nome" + sort_accessed: "Último acesso" + sort_size: "Tamanho" + ascending: "Ascendente" + descending: "Descendente" diff --git a/locales/pt_BR.yaml b/locales/pt_BR.yaml index 5d17c2a..1f41641 100644 --- a/locales/pt_BR.yaml +++ b/locales/pt_BR.yaml @@ -24,3 +24,40 @@ context_menu: cut: "Recortar" paste: "Colar" delete: "Excluir" + +file_chooser: + open: "Abrir" + save: "Salvar como" + places: "Locais" + up: "Subir" + new_folder: "Nova pasta" + cancel: "Cancelar" + close: "Fechar" + mount: "Adicionar uma pasta…" + empty: "Esta pasta está vazia" + name: "Nome do arquivo" + save_button: "Salvar" + folder_name: "Nome da pasta" + create: "Criar" + replace_question: "“%{name}” já existe. Substituir?" + keep: "Manter" + replace: "Substituir" + home: "Pasta pessoal" + desktop: "Área de trabalho" + documents: "Documentos" + downloads: "Downloads" + music: "Música" + pictures: "Imagens" + videos: "Vídeos" + root: "Computador" + folder: "Pasta" + not_found: "Não existe mais" + denied: "Você não tem permissão para abri-lo" + exists: "Já existe um com esse nome" + unsupported: "Indisponível aqui" + sort: "Ordenar" + sort_name: "Nome" + sort_accessed: "Último acesso" + sort_size: "Tamanho" + ascending: "Crescente" + descending: "Decrescente" diff --git a/src/app.rs b/src/app.rs index 10e8ec0..5a110b2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -413,6 +413,23 @@ pub enum SelectionKind Primary, } +/// What an app asks a text field to do, through [`App::take_text_command`], as its menu or its search would: the field's cursor and selection are the runtime's, which the app does not see. +#[ derive( Debug, Clone, PartialEq, Eq ) ] +pub enum TextCommand +{ + /// Put the selection in the clipboard. + Copy, + /// Put the selection in the clipboard and delete it. + Cut, + /// Type the clipboard over the selection. + Paste, + SelectAll, + /// Select the bytes `start..end` of the value, the cursor at `end`, and scroll a multiline field to them; `start == end` only places the cursor. Offsets beyond the value are taken back to its end, and into a character to its start. + Select { start: usize, end: usize }, + /// [`Self::Select`] leaving the focus where it is, as a search does while its own field keeps the keys; a multiline field without the focus shows its selection dimmed. + Reveal { start: usize, end: usize }, +} + /// Trait that application types must implement to integrate with ltk. pub trait App: 'static { @@ -959,6 +976,12 @@ pub trait App: 'static /// The text of the selection [`Self::take_paste_request`] asked for; not called when it is empty. fn on_paste( &mut self, _text: &str ) {} + /// Return `Some( ( id, command ) )` once to have the text field with + /// that [`WidgetId`](crate::types::WidgetId) take the focus and do + /// `command`, after the frame that lays it out is drawn. A field that + /// is not on screen then drops the command. + fn take_text_command( &mut self ) -> Option<( crate::types::WidgetId, TextCommand )> { None } + /// Called on every pointer motion (mouse move, button press, button /// release) and on every primary-finger touch down and motion. `x`, `y` are in physical pixels of the main surface, /// the same coordinate space as widget rects. Default: no-op. @@ -1298,6 +1321,15 @@ pub trait App: 'static /// **Default**: `None` (let the compositor pick). fn window_size_hint( &self ) -> Option<( u32, u32 )> { None } + /// The main window's title, which the compositor's title bar, task + /// switcher and dock show; read once, when the window is made. + /// + /// Only applies to [`ShellMode::Window`] surfaces and to the + /// xdg-shell fallback of a layer surface. + /// + /// **Default**: the [`app_id`](Self::app_id). + fn window_title( &self ) -> String { self.app_id().to_string() } + /// Whether the user may resize an xdg-shell window. /// /// When [`Self::window_size_hint`] is set, ltk pins diff --git a/src/draw/layout.rs b/src/draw/layout.rs index 0fc3e53..8f2235d 100644 --- a/src/draw/layout.rs +++ b/src/draw/layout.rs @@ -571,7 +571,10 @@ pub( crate ) fn layout_and_draw( Element::WindowButton( b ) => b.id, _ => None, }; - other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor, text_scroll ); + if let Some( shown ) = other.draw( canvas, rect, is_focused, is_hovered, is_pressed, cursor_pos, sel_anchor, text_scroll ) + { + if shown != text_scroll { ctx.text_scroll.insert( flat_idx, shown ); } + } if other.is_interactive() { let mut handlers = other.handlers(); diff --git a/src/event_loop/app_data.rs b/src/event_loop/app_data.rs index 544064f..edf076f 100644 --- a/src/event_loop/app_data.rs +++ b/src/event_loop/app_data.rs @@ -119,6 +119,10 @@ pub struct AppData> pub keyboard_focus: SurfaceFocus, /// The current press landed on the widget that already held the focus, not the press that gives it. pub press_on_focused: bool, + /// The multiline field whose scroll bar the current press holds, and how far below the thumb's top it was taken. + pub text_scrollbar_grab: Option<( SurfaceFocus, usize, f32 )>, + /// The cursor, and the value's length, last reported to each text field with `on_cursor`. + pub reported_cursors: HashMap<( SurfaceFocus, usize ), ( usize, usize )>, /// Per-touch-id tracking of which surface each active touch belongs to /// (populated on `down`, cleared on `up`/`cancel`). pub touch_focus: HashMap, @@ -306,6 +310,18 @@ impl AppData self.overlays_dirty = true; } + /// Close the window if [`App::on_close_requested`] lets it; otherwise draw again, since the app may have put up a question. + pub( crate ) fn request_close( &mut self ) + { + if self.app.on_close_requested() + { + self.exit_requested = true; + } else { + self.dirty_caches(); + self.main.request_redraw(); + } + } + /// Smallest remaining time until a pending long-press deadline fires. /// /// Used by the run loop to bound `event_loop.dispatch()` so that a diff --git a/src/event_loop/context_menu.rs b/src/event_loop/context_menu.rs index f0e37b5..f1f07cc 100644 --- a/src/event_loop/context_menu.rs +++ b/src/event_loop/context_menu.rs @@ -87,13 +87,13 @@ impl AppData // selection (so Copy / Cut still operate on the prior // selection). let paste_offset = self.text_input_geometry( focus, widget_idx ) - .and_then( |( rect, value, multiline, secure, align, font_size )| + .and_then( |( rect, value, multiline, secure, align, font )| { let cursor_pos = self.surface( focus ).frame.cursor_state.get( &widget_idx ).copied().unwrap_or( 0 ); let scroll = self.text_scroll( focus, widget_idx ); let canvas = self.surface( focus ).canvas.as_ref()?; Some( crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, widget_idx ), + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, &font, self.text_input_pad( focus, widget_idx ), ) ) } ); diff --git a/src/event_loop/cursor_shape.rs b/src/event_loop/cursor_shape.rs index 4400481..b90abd5 100644 --- a/src/event_loop/cursor_shape.rs +++ b/src/event_loop/cursor_shape.rs @@ -83,7 +83,7 @@ impl AppData let dragging = self.surface( focus ).gesture.dragging_slider.is_some(); let hover_cursor = self.surface( focus ).hovered_idx .and_then( |idx| crate::tree::find_widget( &self.surface( focus ).frame.widget_rects, idx ) ) - .map( |w| w.cursor ) + .map( |w| if self.over_text_scrollbar( focus, w ) { CursorShape::Default } else { w.cursor } ) .unwrap_or( CursorShape::Default ); let resize_cursor = self.resize_edge_under_pointer( focus ).map( resize_edge_to_cursor ); let area_cursor = matches!( focus, SurfaceFocus::Main ) @@ -117,6 +117,16 @@ impl AppData if self.platform.set_cursor_shape( target ) { self.current_cursor_shape = Some( target ); } } + /// Whether the pointer is on the scroll bar of the multiline field `widget`, which takes no text. + fn over_text_scrollbar( &self, focus: SurfaceFocus, widget: &crate::widget::LaidOutWidget ) -> bool + { + if !widget.handlers.is_multiline_text_input() { return false; } + let Some( canvas ) = self.surface( focus ).canvas.as_ref() else { return false }; + let band = canvas.geom_px( crate::widget::text_edit::theme::SCROLLBAR_HIT ); + if self.pointer_pos.x < widget.rect.x + widget.rect.width - band { return false; } + self.text_scrollbar( focus, widget.flat_idx ).is_some_and( |( _, bar, _ )| bar.hit.contains( self.pointer_pos ) ) + } + /// Width (logical pixels) of the resize-grab band along each /// edge of an xdg-toplevel surface. 6 px is the GTK / KWin /// convention. diff --git a/src/event_loop/drag.rs b/src/event_loop/drag.rs index 5c0d0c0..0ff949b 100644 --- a/src/event_loop/drag.rs +++ b/src/event_loop/drag.rs @@ -85,7 +85,8 @@ impl AppData self.stop_button_repeat(); consumed_anything = true; } - if let Some( idx ) = text_idx + let on_scrollbar = |idx: usize| self.text_scrollbar_grab.is_some_and( |( f, i, _ )| f == focus && i == idx ); + if let Some( idx ) = text_idx.filter( |&idx| !on_scrollbar( idx ) ) { // Built-in Copy / Cut / Paste menu near the press. self.show_context_menu( focus, idx, origin ); diff --git a/src/event_loop/setup.rs b/src/event_loop/setup.rs index 35914ed..cce62df 100644 --- a/src/event_loop/setup.rs +++ b/src/event_loop/setup.rs @@ -58,6 +58,8 @@ impl AppData pointer_focus: SurfaceFocus::Main, keyboard_focus: SurfaceFocus::Main, press_on_focused: false, + text_scrollbar_grab: None, + reported_cursors: HashMap::new(), touch_focus: HashMap::new(), cached_view: None, cached_overlays: None, diff --git a/src/event_loop/step.rs b/src/event_loop/step.rs index b6d68be..7c0bb5e 100644 --- a/src/event_loop/step.rs +++ b/src/event_loop/step.rs @@ -5,8 +5,10 @@ use std::time::Duration; -use crate::app::{ App, InvalidationScope, SelectionKind }; +use crate::app::{ App, InvalidationScope, SelectionKind, TextCommand }; use crate::platform::Platform; +use crate::tree::find_handlers; +use crate::widget::WidgetHandlers; use super::{ AppData, SurfaceFocus }; use super::frame::draw_frame; @@ -95,6 +97,92 @@ impl AppData } self.apply_focus_request( drawn ); + self.apply_text_command(); + self.report_text_cursors(); + } + + /// Tell each text field with `on_cursor` where its cursor is, once it has moved or the value's length has changed. + fn report_text_cursors( &mut self ) + { + let surfaces = std::iter::once( SurfaceFocus::Main ) + .chain( self.overlays.keys().map( |id| SurfaceFocus::Overlay( *id ) ) ) + .collect::>(); + let mut reports = Vec::new(); + for focus in surfaces + { + let frame = &self.surface( focus ).frame; + for w in &frame.widget_rects + { + let WidgetHandlers::TextEdit { value, on_cursor: Some( _ ), .. } = &w.handlers else { continue }; + let Some( &at ) = frame.cursor_state.get( &w.flat_idx ) else { continue }; + let seen = ( at.min( value.len() ), value.len() ); + if self.reported_cursors.get( &( focus, w.flat_idx ) ) == Some( &seen ) { continue; } + if let Some( msg ) = w.handlers.text_cursor_msg( seen.0 ) + { + reports.push( ( ( focus, w.flat_idx ), seen, msg ) ); + } + } + } + if reports.is_empty() { return; } + for ( key, seen, msg ) in reports + { + self.reported_cursors.insert( key, seen ); + self.pending_msgs.push( msg ); + } + self.main.request_redraw(); + } + + /// The surface and flat index of the widget the app named `id`, on the main surface or any overlay. + fn find_widget( &self, id: crate::types::WidgetId ) -> Option<( SurfaceFocus, usize )> + { + self.main.frame.widget_rects.iter() + .find( |w| w.id == Some( id ) ) + .map( |w| ( SurfaceFocus::Main, w.flat_idx ) ) + .or_else( || self.overlays.iter().find_map( |( ov_id, surf )| + { + surf.frame.widget_rects.iter() + .find( |w| w.id == Some( id ) ) + .map( |w| ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) ) + } ) ) + } + + /// What the app asked a text field to do, with the field focused first. + fn apply_text_command( &mut self ) + { + let Some( ( id, command ) ) = self.app.take_text_command() else { return }; + let Some( ( focus, idx ) ) = self.find_widget( id ) else { return }; + if !matches!( command, TextCommand::Reveal { .. } ) && self.surface( focus ).focused_idx != Some( idx ) + { + self.set_focus( focus, Some( idx ) ); + } + match command + { + TextCommand::Copy => self.handle_copy( focus ), + TextCommand::Cut => self.handle_cut( focus ), + TextCommand::Paste => self.handle_paste( focus ), + TextCommand::SelectAll => self.handle_select_all( focus ), + TextCommand::Select { start, end } | TextCommand::Reveal { start, end } => self.select_text_range( focus, idx, start, end ), + } + if let Some( ss ) = self.try_surface_mut( focus ) { ss.request_redraw(); } + } + + /// Select the bytes `start..end` of the text field at `idx`, focused or not, the ends taken back to the value and to its characters' starts. + fn select_text_range( &mut self, focus: SurfaceFocus, idx: usize, start: usize, end: usize ) + { + let ss = self.surface( focus ); + let value = ss.pending_text_values.get( &idx ).cloned() + .or_else( || find_handlers( &ss.frame.widget_rects, idx )?.current_value().map( str::to_string ) ); + let Some( value ) = value else { return }; + let boundary = |at: usize| + { + let mut at = at.min( value.len() ); + while !value.is_char_boundary( at ) { at -= 1; } + at + }; + let ( start, end ) = ( boundary( start ), boundary( end ) ); + let frame = &mut self.surface_mut( focus ).frame; + frame.cursor_state.insert( idx, end ); + frame.selection_anchor.insert( idx, start ); } /// What follows the system's word that the last frame of `focus` is on screen: an animation draws its next frame, or, when the app animates only by moving subsurfaces `focus` hosts, leaves the cadence to them. Under the software animation cap a skipped frame keeps the cadence with a bare frame request. @@ -151,19 +239,7 @@ impl AppData .or( retry ) else { return }; - let mut hit: Option<( SurfaceFocus, usize )> = self.main.frame.widget_rects.iter() - .find( |w| w.id == Some( id ) ) - .map( |w| ( SurfaceFocus::Main, w.flat_idx ) ); - if hit.is_none() - { - hit = self.overlays.iter().find_map( |( ov_id, surf )| - { - surf.frame.widget_rects.iter() - .find( |w| w.id == Some( id ) ) - .map( |w| ( SurfaceFocus::Overlay( *ov_id ), w.flat_idx ) ) - } ); - } - if let Some( ( focus, flat_idx ) ) = hit + if let Some( ( focus, flat_idx ) ) = self.find_widget( id ) { self.set_focus( focus, Some( flat_idx ) ); if let Some( ss ) = self.try_surface_mut( focus ) { ss.request_redraw(); } diff --git a/src/event_loop/tests.rs b/src/event_loop/tests.rs index 06021f3..823680c 100644 --- a/src/event_loop/tests.rs +++ b/src/event_loop/tests.rs @@ -7,25 +7,29 @@ use std::sync::MutexGuard; use calloop::EventLoop; -use crate::app::App; +use std::sync::Arc; + +use crate::app::{ App, TextCommand }; use crate::input::event::{ KeyInput, PointerButton, PointerInput, PointerKind }; use crate::osk::Purpose; use crate::platform::mock::{ MockPlatform, Request }; -use crate::types::Rect; +use crate::types::{ Color, Rect, WidgetId }; use crate::widget::WidgetHandlers; -use crate::{ button, column, text_edit, Element, Keysym }; +use crate::{ button, column, text_edit, Element, Keysym, TextHighlight }; use super::{ AppData, Sources, SurfaceFocus, SurfaceState }; use super::surface::Insets; const WIDTH: u32 = 400; const HEIGHT: u32 = 300; +const FIELD: WidgetId = WidgetId( "test.field" ); #[ derive( Clone, Debug, PartialEq ) ] enum Msg { Pressed, Typed( String ), + Cursor( usize ), } #[ derive( Default ) ] @@ -35,6 +39,18 @@ struct TestApp secure: bool, purpose: Purpose, presses: u32, + /// What Tab types in the field. + tab: Option, + /// Runs that make the field a highlighted multiline one. + highlights: Option>>, + command: Option<( WidgetId, TextCommand )>, + /// Whether the field reports its cursor, and where it last did. + track_cursor: bool, + cursor: Option, + /// Whether the app keeps its window open when asked to close it, as one with a question to put does. + refuse_close: bool, + /// The keys handed to the app. + keys: Vec, } impl App for TestApp @@ -49,20 +65,49 @@ impl App for TestApp { column() .push( button( "OK" ).on_press( Msg::Pressed ) ) - .push( text_edit( "", self.value.clone() ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ) ) + .push( self.field() ) .into() } + fn take_text_command( &mut self ) -> Option<( WidgetId, TextCommand )> + { + self.command.take() + } + + fn on_close_requested( &mut self ) -> bool + { + !self.refuse_close + } + + fn on_key_with_modifiers( &mut self, keysym: Keysym, _ctrl: bool, _shift: bool ) -> Option + { + self.keys.push( keysym ); + None + } + fn update( &mut self, msg: Msg ) { match msg { Msg::Pressed => self.presses += 1, Msg::Typed( text ) => self.value = text, + Msg::Cursor( at ) => self.cursor = Some( at ), } } } +impl TestApp +{ + fn field( &self ) -> Element + { + let mut field = text_edit( "", self.value.clone() ).id( FIELD ).secure( self.secure ).purpose( self.purpose ).on_change( Msg::Typed ); + if let Some( tab ) = &self.tab { field = field.tab_text( tab.clone() ); } + if let Some( runs ) = &self.highlights { field = field.multiline( true ).highlights( runs.clone() ); } + if self.track_cursor { field = field.on_cursor( Msg::Cursor ); } + field.into() + } +} + /// Field order matters: `data` holds a handle into `event_loop` and drops first. struct Harness { @@ -136,6 +181,15 @@ impl Harness self.pointer( PointerKind::Release( PointerButton::Left ), r ); } + /// The app's `command` for the field, carried out after a frame, as the run loop does. + fn command( &mut self, command: TextCommand ) + { + self.data.app.command = Some( ( FIELD, command ) ); + self.step(); + self.data.after_draw( true ); + self.step(); + } + fn key( &mut self, keysym: Keysym, text: &str ) { let key = KeyInput::synthetic( keysym, Some( text.to_string() ) ); @@ -264,3 +318,135 @@ fn copying_a_selection_publishes_the_clipboard() assert!( h.step().contains( &Request::Clipboard( "hola".to_string() ) ) ); assert_eq!( h.data.clipboard, "hola" ); } + +#[ test ] +fn tab_types_the_fields_tab_text() +{ + let mut h = Harness::new( TestApp { tab: Some( " ".to_string() ), ..TestApp::default() } ); + h.step(); + let field = h.field_rect(); + h.click( field ); + h.key( Keysym::Tab, "\t" ); + h.step(); + assert_eq!( h.data.app.value, " " ); +} + +#[ test ] +fn tab_types_nothing_in_a_field_without_tab_text() +{ + let mut h = Harness::new( TestApp::default() ); + h.step(); + let field = h.field_rect(); + h.click( field ); + h.key( Keysym::Tab, "\t" ); + h.step(); + assert_eq!( h.data.app.value, "" ); +} + +#[ test ] +fn a_select_command_selects_the_range_and_copy_copies_it() +{ + let mut h = Harness::new( TestApp { value: "hola mundo".to_string(), ..TestApp::default() } ); + h.step(); + h.command( TextCommand::Select { start: 5, end: 10 } ); + h.data.modifiers_changed( true, false, false ); + h.key( Keysym::c, "c" ); + h.step(); + assert_eq!( h.data.clipboard, "mundo" ); +} + +#[ test ] +fn a_select_command_keeps_to_character_boundaries() +{ + let mut h = Harness::new( TestApp { value: "añb".to_string(), ..TestApp::default() } ); + h.step(); + h.command( TextCommand::Select { start: 2, end: 9 } ); + h.command( TextCommand::Copy ); + assert_eq!( h.data.clipboard, "ñb" ); +} + +#[ test ] +fn a_cut_command_cuts_the_selection() +{ + let mut h = Harness::new( TestApp { value: "hola mundo".to_string(), ..TestApp::default() } ); + h.step(); + h.command( TextCommand::Select { start: 0, end: 5 } ); + h.command( TextCommand::Cut ); + assert_eq!( h.data.clipboard, "hola " ); + assert_eq!( h.data.app.value, "mundo" ); +} + +#[ test ] +fn select_all_and_copy_commands_copy_the_whole_value() +{ + let mut h = Harness::new( TestApp { value: "hola".to_string(), ..TestApp::default() } ); + h.step(); + h.command( TextCommand::SelectAll ); + h.command( TextCommand::Copy ); + assert_eq!( h.data.clipboard, "hola" ); +} + +#[ test ] +fn a_highlight_into_a_character_is_drawn_without_it() +{ + let runs = vec![ TextHighlight { range: 0..2, color: Color::rgb( 1.0, 0.0, 0.0 ) }, TextHighlight { range: 3..4, color: Color::rgb( 0.0, 1.0, 0.0 ) } ]; + let mut h = Harness::new( TestApp { value: "añb".to_string(), highlights: Some( Arc::new( runs ) ), ..TestApp::default() } ); + h.step(); + assert!( h.data.main.frame.widget_rects.iter().any( |w| w.handlers.is_text_input() ) ); +} + +#[ test ] +fn a_field_with_on_cursor_hears_where_its_cursor_went_once() +{ + let mut h = Harness::new( TestApp { value: "hola mundo".to_string(), track_cursor: true, ..TestApp::default() } ); + h.step(); + h.command( TextCommand::Select { start: 5, end: 7 } ); + assert_eq!( h.data.app.cursor, Some( 7 ) ); + h.data.app.cursor = None; + h.data.after_draw( true ); + h.step(); + assert_eq!( h.data.app.cursor, None ); +} + +#[ test ] +fn a_close_the_app_refuses_draws_its_view_again() +{ + let mut h = Harness::new( TestApp { refuse_close: true, ..TestApp::default() } ); + h.step(); + h.frame_done(); + h.data.view_dirty = false; + h.data.main.needs_redraw = false; + h.data.request_close(); + assert!( !h.data.exit_requested ); + assert!( h.data.view_dirty && h.data.main.needs_redraw ); + + h.data.app.refuse_close = false; + h.data.request_close(); + assert!( h.data.exit_requested ); +} + +#[ test ] +fn a_key_that_types_nothing_reaches_the_app_from_a_text_field() +{ + let mut h = Harness::new( TestApp::default() ); + h.step(); + let field = h.field_rect(); + h.click( field ); + h.key( Keysym::a, "a" ); + h.key( Keysym::F3, "" ); + h.step(); + assert_eq!( h.data.app.value, "a" ); + assert_eq!( h.data.app.keys, [ Keysym::F3 ] ); +} + +#[ test ] +fn a_reveal_command_selects_without_taking_the_focus() +{ + let mut h = Harness::new( TestApp { value: "hola mundo".to_string(), ..TestApp::default() } ); + h.step(); + h.command( TextCommand::Reveal { start: 5, end: 10 } ); + let idx = h.data.main.frame.widget_rects.iter().find( |w| w.handlers.is_text_input() ).map( |w| w.flat_idx ).expect( "field" ); + assert_ne!( h.data.main.focused_idx, Some( idx ) ); + assert_eq!( h.data.main.frame.cursor_state.get( &idx ), Some( &10 ) ); + assert_eq!( h.data.main.frame.selection_anchor.get( &idx ), Some( &5 ) ); +} diff --git a/src/event_loop/text_editing/cursor.rs b/src/event_loop/text_editing/cursor.rs index f981a6a..6937d82 100644 --- a/src/event_loop/text_editing/cursor.rs +++ b/src/event_loop/text_editing/cursor.rs @@ -99,14 +99,14 @@ impl AppData let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); - let ( rect, _, multiline, secure, _align, font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, _, multiline, secure, _align, font ) = match self.text_input_geometry( focus, idx ) { Some( g ) => g, None => return false, }; if !multiline || secure { return false; } let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; - let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor, font_size ) + let new_cursor = match crate::widget::text_edit::cursor_visual_up( canvas, rect, &value, cursor, &font ) { Some( n ) => n, None => return false, @@ -125,14 +125,14 @@ impl AppData let ( idx, value ) = match self.focused_text_value( focus ) { Some( v ) => v, None => return false }; let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() .unwrap_or( value.len() ).min( value.len() ); - let ( rect, _, multiline, secure, _align, font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, _, multiline, secure, _align, font ) = match self.text_input_geometry( focus, idx ) { Some( g ) => g, None => return false, }; if !multiline || secure { return false; } let canvas = match self.surface( focus ).canvas.as_ref() { Some( c ) => c, None => return false }; - let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor, font_size ) + let new_cursor = match crate::widget::text_edit::cursor_visual_down( canvas, rect, &value, cursor, &font ) { Some( n ) => n, None => return false, @@ -144,6 +144,26 @@ impl AppData true } + /// Move the cursor a page up or down in a multiline field, and the + /// view with it. `false` for any other field, so the key goes on to + /// the app. + pub( crate ) fn handle_cursor_page( &mut self, focus: SurfaceFocus, up: bool, extend: bool ) -> bool + { + let Some( ( idx, value ) ) = self.focused_text_value( focus ) else { return false }; + let Some( ( rect, _, true, false, _, font ) ) = self.text_input_geometry( focus, idx ) else { return false }; + let cursor = self.surface( focus ).frame.cursor_state.get( &idx ).copied() + .unwrap_or( value.len() ).min( value.len() ); + let scroll = self.text_scroll( focus, idx ); + let Some( canvas ) = self.surface( focus ).canvas.as_ref() else { return false }; + let ( moved, scroll ) = crate::widget::text_edit::cursor_visual_page( canvas, rect, &value, cursor, &font, scroll, up ); + let ss = self.surface_mut( focus ); + ss.frame.cursor_state.insert( idx, moved ); + if !extend { ss.frame.selection_anchor.insert( idx, moved ); } + ss.frame.text_scroll.insert( idx, scroll ); + ss.request_redraw(); + true + } + /// Move the cursor to the start of the current *visual* row (Home /// key). On a soft-wrapped logical line that lands at the wrap /// point of the row the caret is on — a second Home press from @@ -156,9 +176,9 @@ impl AppData .unwrap_or( value.len() ).min( value.len() ); let new_cursor = match self.text_input_geometry( focus, idx ) { - Some( ( rect, _, true, false, _, font_size ) ) => match self.surface( focus ).canvas.as_ref() + Some( ( rect, _, true, false, _, font ) ) => match self.surface( focus ).canvas.as_ref() { - Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor, font_size ), + Some( canvas ) => crate::widget::text_edit::cursor_visual_home( canvas, rect, &value, cursor, &font ), None => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), }, _ => value[..cursor].rfind( '\n' ).map( |p| p + 1 ).unwrap_or( 0 ), @@ -179,9 +199,9 @@ impl AppData .unwrap_or( value.len() ).min( value.len() ); let new_cursor = match self.text_input_geometry( focus, idx ) { - Some( ( rect, _, true, false, _, font_size ) ) => match self.surface( focus ).canvas.as_ref() + Some( ( rect, _, true, false, _, font ) ) => match self.surface( focus ).canvas.as_ref() { - Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor, font_size ), + Some( canvas ) => crate::widget::text_edit::cursor_visual_end( canvas, rect, &value, cursor, &font ), None => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), }, _ => value[cursor..].find( '\n' ).map( |p| cursor + p ).unwrap_or( value.len() ), diff --git a/src/event_loop/text_editing/ime.rs b/src/event_loop/text_editing/ime.rs index feb9bbe..251fe4d 100644 --- a/src/event_loop/text_editing/ime.rs +++ b/src/event_loop/text_editing/ime.rs @@ -6,7 +6,8 @@ use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; use crate::platform::Platform; use crate::tree::find_widget; -use crate::types::{ Length, Rect }; +use crate::types::Rect; +use crate::widget::text_edit::TextFont; use crate::widget::WidgetHandlers; impl AppData @@ -71,14 +72,14 @@ impl AppData &self, focus: SurfaceFocus, idx: usize, - ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, Option )> + ) -> Option<( Rect, String, bool, bool, crate::widget::text::TextAlign, TextFont )> { let ss = self.surface( focus ); let widget = find_widget( &ss.frame.widget_rects, idx )?; - let ( value_handler, multiline, secure, align, font_size ) = match &widget.handlers + let ( value_handler, multiline, secure, align, font, line_numbers ) = match &widget.handlers { - WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, .. } => - ( value.clone(), *multiline, *secure, *align, *font_size ), + WidgetHandlers::TextEdit { value, multiline, secure, align, font_size, line_numbers, face, .. } => + ( value.clone(), *multiline, *secure, *align, TextFont { size: *font_size, face: face.clone() }, *line_numbers ), _ => return None, }; // Prefer the *pending* value (typed-but-not-yet-applied) when @@ -87,7 +88,12 @@ impl AppData let value = ss.pending_text_values.get( &idx ) .cloned() .unwrap_or( value_handler ); - Some( ( widget.rect, value, multiline, secure, align, font_size ) ) + let rect = match ss.canvas.as_ref() + { + Some( canvas ) => crate::widget::text_edit::gutter::text_rect( canvas, widget.rect, &value, &font, line_numbers ), + None => widget.rect, + }; + Some( ( rect, value, multiline, secure, align, font ) ) } /// The horizontal inner padding override of the text field at `idx`, as its hit-testing needs. diff --git a/src/event_loop/text_editing/scroll.rs b/src/event_loop/text_editing/scroll.rs index e4b253b..82cff30 100644 --- a/src/event_loop/text_editing/scroll.rs +++ b/src/event_loop/text_editing/scroll.rs @@ -6,7 +6,7 @@ use crate::event_loop::app_data::AppData; use crate::event_loop::surface::SurfaceFocus; use crate::tree::find_widget_at; use crate::types::Point; -use crate::widget::text_edit::scroll::{ TextScroll, Window, WHEEL_LINES }; +use crate::widget::text_edit::scroll::{ Scrollbar, TextScroll, Window, WHEEL_LINES }; use crate::platform::Platform; impl AppData @@ -23,12 +23,12 @@ impl AppData pub( crate ) fn scroll_text_under( &mut self, focus: SurfaceFocus, pos: Point, notches: f32, logical_px: f32 ) -> bool { let Some( idx ) = find_widget_at( &self.surface( focus ).frame.widget_rects, pos ) else { return false }; - let Some( ( rect, value, true, false, _, font_size ) ) = self.text_input_geometry( focus, idx ) else { return false }; + let Some( ( rect, value, true, false, _, font ) ) = self.text_input_geometry( focus, idx ) else { return false }; let ss = self.surface( focus ); let cursor_pos = ss.frame.cursor_state.get( &idx ).copied().unwrap_or( 0 ); let scroll = self.text_scroll( focus, idx ); let Some( canvas ) = ss.canvas.as_ref() else { return false }; - let window = Window::resolve( canvas, rect, &value, font_size, cursor_pos, scroll ); + let window = Window::shown( canvas, rect, &value, &font, cursor_pos, scroll ); let delta_px = if notches != 0.0 { notches * WHEEL_LINES * window.metrics.line_h @@ -42,4 +42,47 @@ impl AppData if moved { ss.request_redraw(); } true } + + /// Take a press on the scroll bar of the multiline field at `idx`: on the thumb it holds it where it was taken, on the track it brings the thumb's middle there. `false` when the press is elsewhere, for the caller to place the cursor. + pub( crate ) fn handle_text_scrollbar_press( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) -> bool + { + self.text_scrollbar_grab = None; + let Some( ( window, bar, cursor_pos ) ) = self.text_scrollbar( focus, idx ) else { return false }; + if !bar.hit.contains( pos ) { return false; } + let on_thumb = pos.y >= bar.thumb.y && pos.y <= bar.thumb.y + bar.thumb.height; + let grab = if on_thumb { pos.y - bar.thumb.y } else { bar.thumb.height / 2.0 }; + self.text_scrollbar_grab = Some( ( focus, idx, grab ) ); + self.scroll_text_to( focus, idx, window.first_line_for( &bar, pos.y - grab ), window.first_line, cursor_pos ); + true + } + + /// Follow a press that holds the scroll bar of the field at `idx`. `false` when the press does not, for the caller to extend the selection. + pub( crate ) fn handle_text_scrollbar_drag( &mut self, focus: SurfaceFocus, idx: usize, pos: Point ) -> bool + { + let Some( ( grab_focus, grab_idx, grab ) ) = self.text_scrollbar_grab else { return false }; + if grab_focus != focus || grab_idx != idx { return false; } + if let Some( ( window, bar, cursor_pos ) ) = self.text_scrollbar( focus, idx ) + { + self.scroll_text_to( focus, idx, window.first_line_for( &bar, pos.y - grab ), window.first_line, cursor_pos ); + } + true + } + + pub( crate ) fn text_scrollbar( &self, focus: SurfaceFocus, idx: usize ) -> Option<( Window, Scrollbar, usize )> + { + let Some( ( rect, value, true, false, _, font ) ) = self.text_input_geometry( focus, idx ) else { return None }; + let ss = self.surface( focus ); + let cursor_pos = ss.frame.cursor_state.get( &idx ).copied().unwrap_or( 0 ); + let canvas = ss.canvas.as_ref()?; + let window = Window::shown( canvas, rect, &value, &font, cursor_pos, self.text_scroll( focus, idx ) ); + let bar = window.scrollbar( canvas, rect )?; + Some( ( window, bar, cursor_pos ) ) + } + + fn scroll_text_to( &mut self, focus: SurfaceFocus, idx: usize, first_line: usize, shown: usize, cursor_pos: usize ) + { + let ss = self.surface_mut( focus ); + ss.frame.text_scroll.insert( idx, TextScroll { first_line, cursor_pos, remainder: 0.0 } ); + if first_line != shown { ss.request_redraw(); } + } } diff --git a/src/event_loop/text_editing/selection.rs b/src/event_loop/text_editing/selection.rs index 8590919..a615f39 100644 --- a/src/event_loop/text_editing/selection.rs +++ b/src/event_loop/text_editing/selection.rs @@ -89,7 +89,7 @@ impl AppData Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus { return; } - let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, value, multiline, secure, align, font ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, @@ -101,7 +101,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, &font, self.text_input_pad( focus, idx ), ) }; let ( start, end ) = word_bounds_at( &value, click_byte ); @@ -127,7 +127,7 @@ impl AppData Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus && !self.press_on_focused { return; } - let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, value, multiline, secure, align, font ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, @@ -139,7 +139,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, &font, self.text_input_pad( focus, idx ), ) }; let ss = self.surface_mut( focus ); @@ -162,7 +162,7 @@ impl AppData Some( WidgetHandlers::TextEdit { select_on_focus: true, .. } ), ); if select_on_focus && !self.press_on_focused { return; } - let ( rect, value, multiline, secure, align, font_size ) = match self.text_input_geometry( focus, idx ) + let ( rect, value, multiline, secure, align, font ) = match self.text_input_geometry( focus, idx ) { Some( v ) => v, None => return, @@ -174,7 +174,7 @@ impl AppData let ss = self.surface( focus ); let canvas = match ss.canvas.as_ref() { Some( c ) => c, None => return }; crate::widget::text_edit::byte_offset_at( - canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, font_size, self.text_input_pad( focus, idx ), + canvas, rect, pos, &value, multiline, secure, cursor_pos, scroll, align, &font, self.text_input_pad( focus, idx ), ) }; let ss = self.surface_mut( focus ); diff --git a/src/file_chooser/mod.rs b/src/file_chooser/mod.rs new file mode 100644 index 0000000..b3af020 --- /dev/null +++ b/src/file_chooser/mod.rs @@ -0,0 +1,428 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! A dialog that picks a file to open, or a folder and a name to save one +//! as, over [`crate::files`], alike natively and in a browser. The app +//! holds it, puts its [`FileChooser::view`] in its own view, and hands its +//! messages to [`FileChooser::update`], which says when the user chose. +//! +//! ```rust,no_run +//! # use ltk::{ ChannelSender, Chosen, Element, FileChooser, FileChooserMsg }; +//! # #[ derive( Clone ) ] enum Msg { Chooser( FileChooserMsg ) } +//! # struct App { chooser: Option>, sender: ChannelSender } +//! # impl App { +//! fn open( &mut self ) +//! { +//! self.chooser = Some( FileChooser::open( &self.sender, Msg::Chooser ).filter( &[ "text/*" ] ) ); +//! } +//! +//! fn update( &mut self, msg: Msg ) +//! { +//! let Msg::Chooser( msg ) = msg; +//! let Some( chooser ) = &mut self.chooser else { return }; +//! match chooser.update( msg ) +//! { +//! Some( Chosen::Path( path ) ) => { self.chooser = None; /* read path */ } +//! Some( Chosen::Cancelled ) => self.chooser = None, +//! None => {} +//! } +//! } +//! # fn view( &self ) -> Element { self.chooser.as_ref().unwrap().view() } +//! # } +//! ``` + +mod places; +mod sort; +mod view; +#[ cfg( test ) ] +mod tests; + +pub use places::{ home, Place, PlaceKind }; +pub use sort::{ Directions, Sort, SortKey }; + +use std::io; +use std::path::{ Path, PathBuf }; +use std::sync::Arc; +use std::time::SystemTime; + +use crate::app::ChannelSender; +use crate::widget::Element; +use crate::files::{ self, Kind }; +use crate::types::WidgetId; + +/// The field with the name to save as. +pub const NAME: WidgetId = WidgetId( "ltk.file_chooser.name" ); +/// The field with the name of a folder to create. +pub const FOLDER_NAME: WidgetId = WidgetId( "ltk.file_chooser.folder" ); + +#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] +pub enum Purpose +{ + Open, + Save, +} + +/// What the user chose. +#[ derive( Clone, Debug, PartialEq, Eq ) ] +pub enum Chosen +{ + /// The file to open, or the one to save as, which the user agreed to replace if it was there. + Path( PathBuf ), + Cancelled, +} + +/// What happens in a file chooser, for [`FileChooser::update`]. +#[ derive( Clone, Debug ) ] +pub enum FileChooserMsg +{ + Listed( PathBuf, Result, String> ), + Enter( PathBuf ), + Up, + Pick( PathBuf ), + Name( String ), + Confirm, + Replace, + KeepFile, + NewFolder, + FolderName( String ), + CreateFolder, + CancelFolder, + FolderCreated( PathBuf, Result<(), String> ), + Mount, + /// `None` when the user dismissed the browser's picker. + Mounted( Result> ), + TogglePlaces, + ToggleSort, + SetSort( Sort ), + CloseNotice, + Cancel, +} + +/// One entry of the folder shown. +#[ derive( Clone, Debug, PartialEq ) ] +pub( crate ) struct Item +{ + pub name: String, + pub path: PathBuf, + pub is_dir: bool, + pub size: u64, + pub mime: Option<&'static str>, + pub modified: Option, + pub accessed: Option, +} + +pub struct FileChooser +{ + purpose: Purpose, + dir: PathBuf, + /// Folders first, then the files the filter lets through, in `sort`'s order, the hidden ones left out. + items: Vec, + sort: Sort, + sort_open: bool, + name: String, + error: Option, + loading: bool, + filters: Vec, + compact: bool, + places_open: bool, + /// The file saving would replace, while the user is asked. + replacing: Option, + /// The name of the folder being created. + new_folder: Option, + notice: Option, + focus: Option, + sender: ChannelSender, + wrap: Arc Msg + Send + Sync>, +} + +impl FileChooser +{ + /// A chooser of a file to open, starting at the home folder. `wrap` makes the app's message of the chooser's own. + pub fn open( sender: &ChannelSender, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static ) -> Self + { + Self::new( Purpose::Open, String::new(), sender, Arc::new( wrap ) ) + } + + /// A chooser of where to save a file, offering `name`, which it gives the focus. + pub fn save( sender: &ChannelSender, wrap: impl Fn( FileChooserMsg ) -> Msg + Send + Sync + 'static, name: impl Into ) -> Self + { + let mut chooser = Self::new( Purpose::Save, name.into(), sender, Arc::new( wrap ) ); + chooser.focus = Some( NAME ); + chooser + } + + fn new( purpose: Purpose, name: String, sender: &ChannelSender, wrap: Arc Msg + Send + Sync> ) -> Self + { + let mut chooser = Self + { + purpose, + dir: PathBuf::new(), + items: Vec::new(), + sort: Sort::default(), + sort_open: false, + name, + error: None, + loading: true, + filters: Vec::new(), + compact: false, + places_open: false, + replacing: None, + new_folder: None, + notice: None, + focus: None, + sender: sender.clone(), + wrap, + }; + chooser.enter( home() ); + chooser + } + + /// Start at `dir` instead of the home folder. + pub fn dir( mut self, dir: impl Into ) -> Self + { + self.enter( dir.into() ); + self + } + + /// Show only the files of these types besides the folders: MIME types such as `text/plain`, or a whole kind such as `image/*`. + pub fn filter( mut self, types: &[ &str ] ) -> Self + { + self.filters = types.iter().map( |t| t.to_string() ).collect(); + let dir = self.dir.clone(); + self.enter( dir ); + self + } + + /// In this order instead of by name, as the app kept it from [`Self::current_sort`]. + pub fn sort( mut self, sort: Sort ) -> Self + { + self.set_sort( sort ); + self + } + + pub fn current_sort( &self ) -> Sort + { + self.sort + } + + /// Laid out for a narrow screen: the places behind a button instead of beside the files. + pub fn compact( mut self, on: bool ) -> Self + { + self.compact = on; + self + } + + pub fn purpose( &self ) -> Purpose + { + self.purpose + } + + pub fn current_dir( &self ) -> &Path + { + &self.dir + } + + /// The field to focus, once after it changed; an app hands it on from [`crate::App::take_focus_request`]. + pub fn take_focus_request( &mut self ) -> Option + { + self.focus.take() + } + + pub fn view( &self ) -> Element + { + view::view( self ) + } + + /// Take one of the chooser's messages; `Some` once the user chose. + pub fn update( &mut self, msg: FileChooserMsg ) -> Option + { + match msg + { + FileChooserMsg::Listed( dir, result ) => self.listed( &dir, result ), + FileChooserMsg::Enter( dir ) => + { + self.places_open = false; + self.enter( dir ); + } + FileChooserMsg::Up => if let Some( parent ) = self.parent() { self.enter( parent ); }, + FileChooserMsg::Pick( path ) => match self.purpose + { + Purpose::Open => return Some( Chosen::Path( path ) ), + Purpose::Save => + { + self.name = path.file_name().map( |name| name.to_string_lossy().into_owned() ).unwrap_or_default(); + self.replacing = None; + self.focus = Some( NAME ); + } + }, + FileChooserMsg::Name( name ) => + { + self.name = name; + self.replacing = None; + } + FileChooserMsg::Confirm => + { + let target = self.target()?; + if self.replaces() + { + self.replacing = Some( target ); + return None; + } + return Some( Chosen::Path( target ) ); + } + FileChooserMsg::Replace => return self.replacing.take().map( Chosen::Path ), + FileChooserMsg::KeepFile => + { + self.replacing = None; + self.focus = Some( NAME ); + } + FileChooserMsg::NewFolder => + { + self.new_folder = Some( String::new() ); + self.focus = Some( FOLDER_NAME ); + } + FileChooserMsg::FolderName( name ) => if let Some( folder ) = &mut self.new_folder { *folder = name; }, + FileChooserMsg::CreateFolder => + { + let path = self.new_folder_path()?; + self.new_folder = None; + let wrap = Arc::clone( &self.wrap ); + let made = path.clone(); + files::create_dir( path, &self.sender, move |result| wrap( FileChooserMsg::FolderCreated( made, result.map_err( |e| describe( &e ) ) ) ) ); + } + FileChooserMsg::CancelFolder => self.new_folder = None, + FileChooserMsg::FolderCreated( path, result ) => match result + { + Ok( () ) => self.enter( path ), + Err( error ) => self.notice = Some( error ), + }, + FileChooserMsg::Mount => + { + let wrap = Arc::clone( &self.wrap ); + files::mount( &self.sender, move |result| wrap( FileChooserMsg::Mounted( result.map_err( |e| + { + ( e.kind() != io::ErrorKind::Interrupted ).then( || describe( &e ) ) + } ) ) ) ); + } + FileChooserMsg::Mounted( result ) => match result + { + Ok( mount ) => self.enter( mount.path ), + Err( Some( error ) ) => self.notice = Some( error ), + Err( None ) => {} + }, + FileChooserMsg::TogglePlaces => self.places_open = !self.places_open, + FileChooserMsg::ToggleSort => self.sort_open = !self.sort_open, + FileChooserMsg::SetSort( sort ) => self.set_sort( sort ), + FileChooserMsg::CloseNotice => self.notice = None, + FileChooserMsg::Cancel => return Some( Chosen::Cancelled ), + } + None + } + + fn msg( &self, msg: FileChooserMsg ) -> Msg + { + ( self.wrap )( msg ) + } + + fn enter( &mut self, dir: PathBuf ) + { + self.dir = dir.clone(); + self.items.clear(); + self.error = None; + self.loading = true; + self.replacing = None; + let wrap = Arc::clone( &self.wrap ); + files::list( dir.clone(), &self.sender, move |result| wrap( FileChooserMsg::Listed( dir, result.map_err( |e| describe( &e ) ) ) ) ); + } + + /// The listing of `dir`, unless the chooser has moved on to another folder. + fn listed( &mut self, dir: &Path, result: Result, String> ) + { + if dir != self.dir { return; } + self.loading = false; + match result + { + Ok( entries ) => + { + self.items = items( dir, entries, &self.filters, self.sort ); + self.error = None; + } + Err( error ) => self.error = Some( error ), + } + } + + fn set_sort( &mut self, sort: Sort ) + { + self.sort = sort; + self.items.sort_by( |a, b| sort.compare( a, b ) ); + } + + fn parent( &self ) -> Option + { + self.dir.parent().map( Path::to_path_buf ) + } + + /// Where to save, once there is a name. + fn target( &self ) -> Option + { + valid_name( &self.name ).map( |name| self.dir.join( name ) ) + } + + /// Whether saving to the target would replace a file the folder holds. + fn replaces( &self ) -> bool + { + let name = self.name.trim(); + self.items.iter().any( |item| !item.is_dir && item.name == name ) + } + + fn new_folder_path( &self ) -> Option + { + valid_name( self.new_folder.as_deref()? ).map( |name| self.dir.join( name ) ) + } +} + +fn valid_name( name: &str ) -> Option<&str> +{ + let name = name.trim(); + ( !name.is_empty() && name != "." && name != ".." && !name.contains( '/' ) ).then_some( name ) +} + +/// What a folder shows: the hidden entries and the files no filter takes left out, folders first, in `sort`'s order. +pub( crate ) fn items( dir: &Path, entries: Vec, filters: &[ String ], sort: Sort ) -> Vec +{ + let mut items: Vec = entries.into_iter() + .filter_map( |entry| + { + let name = entry.name.to_string_lossy().into_owned(); + if name.starts_with( '.' ) { return None; } + let is_dir = entry.kind == Kind::Directory; + let mime = if is_dir { None } else { files::mime_type( &name ) }; + if !is_dir && !filters.is_empty() && !mime.is_some_and( |mime| filters.iter().any( |f| matches( f, mime ) ) ) { return None; } + Some( Item { path: dir.join( &entry.name ), is_dir, size: entry.size, mime, modified: entry.modified, accessed: entry.accessed, name } ) + } ) + .collect(); + items.sort_by( |a, b| sort.compare( a, b ) ); + items +} + +fn matches( filter: &str, mime: &str ) -> bool +{ + match filter.strip_suffix( "/*" ) + { + Some( kind ) => mime.split_once( '/' ).is_some_and( |( top, _ )| top == kind ), + None => filter == mime, + } +} + +/// An error in the user's words. +fn describe( error: &io::Error ) -> String +{ + match error.kind() + { + io::ErrorKind::NotFound => rust_i18n::t!( "file_chooser.not_found" ).to_string(), + io::ErrorKind::PermissionDenied => rust_i18n::t!( "file_chooser.denied" ).to_string(), + io::ErrorKind::AlreadyExists => rust_i18n::t!( "file_chooser.exists" ).to_string(), + io::ErrorKind::Unsupported => rust_i18n::t!( "file_chooser.unsupported" ).to_string(), + _ => error.to_string(), + } +} diff --git a/src/file_chooser/places.rs b/src/file_chooser/places.rs new file mode 100644 index 0000000..723d87e --- /dev/null +++ b/src/file_chooser/places.rs @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The folders a file chooser offers to start from. + +use std::path::{ Path, PathBuf }; + +use crate::files; + +/// Where a browser's desktop mounts the folders of the user's computer, which a page has no home folder beside. +const MEDIA: &str = "/media"; + +#[ derive( Clone, Copy, Debug, PartialEq, Eq ) ] +pub enum PlaceKind +{ + Home, + Desktop, + Documents, + Downloads, + Music, + Pictures, + Videos, + Root, + /// A folder of the user's computer mounted in a browser. + Mount, +} + +#[ derive( Clone, Debug, PartialEq, Eq ) ] +pub struct Place +{ + pub kind: PlaceKind, + pub path: PathBuf, + /// A mount's own name; the others are named by their kind. + pub name: Option, +} + +const USER_DIRS: [ ( PlaceKind, &str, &str ); 6 ] = [ + ( PlaceKind::Desktop, "DESKTOP", "Desktop" ), + ( PlaceKind::Documents, "DOCUMENTS", "Documents" ), + ( PlaceKind::Downloads, "DOWNLOAD", "Downloads" ), + ( PlaceKind::Music, "MUSIC", "Music" ), + ( PlaceKind::Pictures, "PICTURES", "Pictures" ), + ( PlaceKind::Videos, "VIDEOS", "Videos" ), +]; + +/// The user's home folder, or in a browser where its folders are mounted. +pub fn home() -> PathBuf +{ + std::env::var_os( "HOME" ).map_or_else( || PathBuf::from( MEDIA ), PathBuf::from ) +} + +/// The home folder, the user's folders that exist, the root, and the mounted folders. +pub fn standard() -> Vec +{ + let home = home(); + let mut out = vec![ Place { kind: PlaceKind::Home, path: home.clone(), name: None } ]; + for ( kind, key, fallback ) in USER_DIRS + { + let path = user_dir( &home, key ).unwrap_or_else( || home.join( fallback ) ); + if path.is_dir() { out.push( Place { kind, path, name: None } ); } + } + if Path::new( "/" ).is_dir() { out.push( Place { kind: PlaceKind::Root, path: PathBuf::from( "/" ), name: None } ); } + for mount in files::mounts().unwrap_or_default() + { + out.push( Place { kind: PlaceKind::Mount, path: mount.path, name: Some( mount.name ) } ); + } + out +} + +/// From xdg-user-dirs; `None` when set to the home itself, which is how it disables one. +fn user_dir( home: &Path, key: &str ) -> Option +{ + let config = std::env::var_os( "XDG_CONFIG_HOME" ).map( PathBuf::from ).filter( |p| p.is_absolute() ).unwrap_or_else( || home.join( ".config" ) ); + let text = std::fs::read_to_string( config.join( "user-dirs.dirs" ) ).ok()?; + parse_user_dir( &text, home, key ) +} + +pub( crate ) fn parse_user_dir( text: &str, home: &Path, key: &str ) -> Option +{ + let wanted = format!( "XDG_{key}_DIR" ); + let value = text.lines() + .filter( |l| !l.trim_start().starts_with( '#' ) ) + .filter_map( |l| l.split_once( '=' ) ) + .find( |( k, _ )| k.trim() == wanted )? + .1.trim().trim_matches( '"' ).to_string(); + let path = match value.strip_prefix( "$HOME" ) + { + Some( rest ) => home.join( rest.trim_start_matches( '/' ) ), + None => PathBuf::from( value ), + }; + ( path != home ).then_some( path ) +} diff --git a/src/file_chooser/sort.rs b/src/file_chooser/sort.rs new file mode 100644 index 0000000..645fafd --- /dev/null +++ b/src/file_chooser/sort.rs @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The order of a file chooser's folder, as Crate's. + +use std::cmp::Ordering; + +use super::Item; + +#[ derive( Clone, Copy, Debug, Default, PartialEq, Eq ) ] +pub enum SortKey +{ + #[ default ] + Name, + Accessed, + Size, +} + +/// Whether each key sorts descending; each keeps its own direction, ascending until chosen otherwise. +#[ derive( Clone, Copy, Debug, Default, PartialEq, Eq ) ] +pub struct Directions +{ + pub name: bool, + pub accessed: bool, + pub size: bool, +} + +impl Directions +{ + pub fn descending( self, key: SortKey ) -> bool + { + match key + { + SortKey::Name => self.name, + SortKey::Accessed => self.accessed, + SortKey::Size => self.size, + } + } + + fn set( &mut self, key: SortKey, descending: bool ) + { + match key + { + SortKey::Name => self.name = descending, + SortKey::Accessed => self.accessed = descending, + SortKey::Size => self.size = descending, + } + } +} + +#[ derive( Clone, Copy, Debug, Default, PartialEq, Eq ) ] +pub struct Sort +{ + pub key: SortKey, + pub directions: Directions, +} + +impl Sort +{ + pub fn descending( self ) -> bool + { + self.directions.descending( self.key ) + } + + /// By `key`, in the direction it last had. + pub fn select( self, key: SortKey ) -> Self + { + Sort { key, ..self } + } + + pub fn with_direction( mut self, key: SortKey, descending: bool ) -> Self + { + self.directions.set( key, descending ); + self.select( key ) + } + + /// Folders always come first; the direction applies within each group, ties going by name. + pub( crate ) fn compare( self, a: &Item, b: &Item ) -> Ordering + { + let by_key = match self.key + { + SortKey::Name => Ordering::Equal, + SortKey::Accessed => a.accessed.cmp( &b.accessed ), + SortKey::Size => if a.is_dir || b.is_dir { Ordering::Equal } else { a.size.cmp( &b.size ) }, + }; + let by_name = a.name.to_lowercase().cmp( &b.name.to_lowercase() ).then_with( || a.name.cmp( &b.name ) ); + let order = by_key.then( by_name ); + b.is_dir.cmp( &a.is_dir ).then( if self.descending() { order.reverse() } else { order } ) + } +} diff --git a/src/file_chooser/tests.rs b/src/file_chooser/tests.rs new file mode 100644 index 0000000..79f66ca --- /dev/null +++ b/src/file_chooser/tests.rs @@ -0,0 +1,164 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::path::{ Path, PathBuf }; +use std::time::{ Duration, UNIX_EPOCH }; + +use crate::app::ChannelSender; +use crate::files::{ Entry, Kind }; + +use super::{ items, matches, places, valid_name, view, Chosen, FileChooser, FileChooserMsg, Sort, SortKey }; + +#[ derive( Clone, Debug ) ] +enum Msg +{ + Chooser( FileChooserMsg ), +} + +fn sender() -> ChannelSender +{ + let ( tx, rx ) = calloop::channel::channel(); + std::mem::forget( rx ); + ChannelSender::from_calloop( tx ) +} + +fn entry( name: &str, kind: Kind ) -> Entry +{ + Entry { name: name.into(), kind, size: 0, modified: None, accessed: None } +} + +fn listed( chooser: &mut FileChooser, dir: &str, names: &[ ( &str, Kind ) ] ) +{ + let entries = names.iter().map( |( name, kind )| entry( name, *kind ) ).collect(); + assert_eq!( chooser.update( FileChooserMsg::Listed( PathBuf::from( dir ), Ok( entries ) ) ), None ); +} + +#[ test ] +fn a_folder_shows_folders_first_by_name_without_the_hidden() +{ + let found = vec![ entry( "b.txt", Kind::File ), entry( ".hidden", Kind::File ), entry( "Zeta", Kind::Directory ), entry( "A.txt", Kind::File ), entry( "alpha", Kind::Directory ) ]; + let names: Vec = items( Path::new( "/srv" ), found, &[], Sort::default() ).into_iter().map( |item| item.name ).collect(); + assert_eq!( names, [ "alpha", "Zeta", "A.txt", "b.txt" ] ); +} + +fn sorted( key: SortKey, descending: bool ) -> Vec +{ + let at = |secs: u64| Some( UNIX_EPOCH + Duration::from_secs( secs ) ); + let found = vec! + [ + Entry { name: "b.txt".into(), kind: Kind::File, size: 30, modified: None, accessed: at( 1 ) }, + Entry { name: "Zeta".into(), kind: Kind::Directory, size: 0, modified: None, accessed: at( 5 ) }, + Entry { name: "A.txt".into(), kind: Kind::File, size: 10, modified: None, accessed: at( 3 ) }, + Entry { name: "alpha".into(), kind: Kind::Directory, size: 0, modified: None, accessed: at( 2 ) }, + Entry { name: "c.txt".into(), kind: Kind::File, size: 20, modified: None, accessed: at( 2 ) }, + ]; + items( Path::new( "/srv" ), found, &[], Sort::default().with_direction( key, descending ) ).into_iter().map( |item| item.name ).collect() +} + +#[ test ] +fn folders_lead_by_size_and_by_last_access_in_either_direction() +{ + assert_eq!( sorted( SortKey::Name, true ), [ "Zeta", "alpha", "c.txt", "b.txt", "A.txt" ] ); + assert_eq!( sorted( SortKey::Size, false ), [ "alpha", "Zeta", "A.txt", "c.txt", "b.txt" ] ); + assert_eq!( sorted( SortKey::Size, true ), [ "Zeta", "alpha", "b.txt", "c.txt", "A.txt" ] ); + assert_eq!( sorted( SortKey::Accessed, false ), [ "alpha", "Zeta", "b.txt", "c.txt", "A.txt" ] ); + assert_eq!( sorted( SortKey::Accessed, true ), [ "Zeta", "alpha", "A.txt", "c.txt", "b.txt" ] ); +} + +#[ test ] +fn each_key_keeps_its_direction_and_a_new_order_sorts_the_folder_shown() +{ + let sort = Sort::default().with_direction( SortKey::Size, true ).select( SortKey::Name ); + assert!( !sort.descending() ); + assert!( sort.select( SortKey::Size ).descending() ); + + let mut chooser = FileChooser::open( &sender(), Msg::Chooser ).dir( "/srv" ); + listed( &mut chooser, "/srv", &[ ( "a.txt", Kind::File ), ( "b.txt", Kind::File ) ] ); + chooser.update( FileChooserMsg::SetSort( Sort::default().with_direction( SortKey::Name, true ) ) ); + let names: Vec<&str> = chooser.items.iter().map( |item| item.name.as_str() ).collect(); + assert_eq!( names, [ "b.txt", "a.txt" ] ); + assert!( chooser.current_sort().descending() ); +} + +#[ test ] +fn a_filter_takes_a_type_or_a_whole_kind() +{ + assert!( matches( "text/*", "text/x-rust" ) ); + assert!( matches( "text/plain", "text/plain" ) ); + assert!( !matches( "text/plain", "text/x-rust" ) ); + assert!( !matches( "image/*", "text/plain" ) ); +} + +#[ test ] +fn a_name_holds_no_slash_and_is_not_a_dot() +{ + assert_eq!( valid_name( " nota.txt " ), Some( "nota.txt" ) ); + assert_eq!( valid_name( "" ), None ); + assert_eq!( valid_name( ".." ), None ); + assert_eq!( valid_name( "a/b" ), None ); +} + +#[ test ] +fn the_user_folders_come_from_xdg_user_dirs() +{ + let home = Path::new( "/home/ana" ); + let text = "# comment\nXDG_DOCUMENTS_DIR=\"$HOME/Documentos\"\nXDG_MUSIC_DIR=\"$HOME/\"\nXDG_VIDEOS_DIR=\"/srv/videos\"\n"; + assert_eq!( places::parse_user_dir( text, home, "DOCUMENTS" ), Some( PathBuf::from( "/home/ana/Documentos" ) ) ); + assert_eq!( places::parse_user_dir( text, home, "MUSIC" ), None ); + assert_eq!( places::parse_user_dir( text, home, "VIDEOS" ), Some( PathBuf::from( "/srv/videos" ) ) ); + assert_eq!( places::parse_user_dir( text, home, "PICTURES" ), None ); +} + +#[ test ] +fn sizes_read_in_the_largest_unit() +{ + assert_eq!( view::size( 512 ), "512 B" ); + assert_eq!( view::size( 1536 ), "1.5 KB" ); + assert_eq!( view::size( 20 * 1024 * 1024 ), "20 MB" ); +} + +#[ test ] +fn opening_chooses_the_file_picked_and_folders_are_entered() +{ + let mut chooser = FileChooser::open( &sender(), Msg::Chooser ).dir( "/srv" ); + listed( &mut chooser, "/srv", &[ ( "docs", Kind::Directory ), ( "a.txt", Kind::File ) ] ); + assert_eq!( chooser.update( FileChooserMsg::Enter( PathBuf::from( "/srv/docs" ) ) ), None ); + assert_eq!( chooser.current_dir(), Path::new( "/srv/docs" ) ); + assert_eq!( chooser.update( FileChooserMsg::Pick( PathBuf::from( "/srv/docs/b.txt" ) ) ), Some( Chosen::Path( PathBuf::from( "/srv/docs/b.txt" ) ) ) ); + assert_eq!( chooser.update( FileChooserMsg::Cancel ), Some( Chosen::Cancelled ) ); +} + +#[ test ] +fn the_listing_of_a_folder_left_is_ignored() +{ + let mut chooser = FileChooser::open( &sender(), Msg::Chooser ).dir( "/srv" ); + chooser.update( FileChooserMsg::Up ); + listed( &mut chooser, "/srv", &[ ( "a.txt", Kind::File ) ] ); + assert!( chooser.items.is_empty() ); + assert!( chooser.loading ); +} + +#[ test ] +fn saving_over_a_file_asks_first() +{ + let mut chooser = FileChooser::save( &sender(), Msg::Chooser, "nota.txt" ).dir( "/srv" ); + assert_eq!( chooser.take_focus_request(), Some( super::NAME ) ); + listed( &mut chooser, "/srv", &[ ( "nota.txt", Kind::File ) ] ); + assert_eq!( chooser.update( FileChooserMsg::Confirm ), None ); + assert_eq!( chooser.replacing, Some( PathBuf::from( "/srv/nota.txt" ) ) ); + assert_eq!( chooser.update( FileChooserMsg::KeepFile ), None ); + assert_eq!( chooser.update( FileChooserMsg::Confirm ), None ); + assert_eq!( chooser.update( FileChooserMsg::Replace ), Some( Chosen::Path( PathBuf::from( "/srv/nota.txt" ) ) ) ); + + assert_eq!( chooser.update( FileChooserMsg::Name( "otra.txt".into() ) ), None ); + assert_eq!( chooser.update( FileChooserMsg::Confirm ), Some( Chosen::Path( PathBuf::from( "/srv/otra.txt" ) ) ) ); +} + +#[ test ] +fn picking_a_file_to_save_as_takes_its_name() +{ + let mut chooser = FileChooser::save( &sender(), Msg::Chooser, "" ).dir( "/srv" ); + assert_eq!( chooser.update( FileChooserMsg::Confirm ), None ); + assert_eq!( chooser.update( FileChooserMsg::Pick( PathBuf::from( "/srv/vieja.txt" ) ) ), None ); + assert_eq!( chooser.name, "vieja.txt" ); +} diff --git a/src/file_chooser/view.rs b/src/file_chooser/view.rs new file mode 100644 index 0000000..1dd291b --- /dev/null +++ b/src/file_chooser/view.rs @@ -0,0 +1,369 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +use std::cell::RefCell; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; +use std::time::SystemTime; + +use rust_i18n::t; + +use crate::{ button, column, container, flex, icon_button, list_item, row, scroll, separator, text, text_edit }; +use crate::{ ButtonVariant, Color, Element, ImageData, Length }; + +use super::places::{ self, Place, PlaceKind }; +use super::{ FileChooser, FileChooserMsg, Item, Purpose, SortKey, FOLDER_NAME, NAME }; + +const ROW_H: f32 = 44.0; +const ROW_H_SUB: f32 = 56.0; +const TOOL: f32 = 40.0; +const TOOL_ICON: f32 = 22.0; +/// Twice the drawn size, so icons stay sharp at scale 2. +const RASTER_PX: u32 = 48; + +const UP: &str = "general/up"; +const CLOSE: &str = "navigation/close"; +const NEW_FOLDER: &str = "actions/add"; +const PLACES: &str = "navigation/menu-burger"; +const FOLDER: &str = "archives/folder-files"; +const ASCENDING: &str = "general/up-simple"; +const DESCENDING: &str = "general/down-simple"; + +thread_local! +{ + static ICONS: RefCell>> = RefCell::new( HashMap::new() ); +} + +fn icon( name: &'static str, tint: Color ) -> Option +{ + let channel = |v: f32| ( v.clamp( 0.0, 1.0 ) * 255.0 ) as u8; + let key = ( name, [ channel( tint.r ), channel( tint.g ), channel( tint.b ), channel( tint.a ) ] ); + ICONS.with( |icons| icons.borrow_mut().entry( key ).or_insert_with( || crate::theme_icon_tinted( name, RASTER_PX, tint ) ).clone() ) +} + +pub( super ) fn view( chooser: &FileChooser ) -> Element +{ + let palette = crate::theme::palette(); + let mut page = column() + .padding( 0.0 ) + .spacing( 0.0 ) + .push( container( header( chooser ) ).background( palette.surface_alt ) ) + .push( separator() ); + if chooser.sort_open { page = page.push( sort_bar( chooser ) ).push( separator() ); } + page = page.push( flex( body( chooser ) ) ); + if let Some( notice ) = &chooser.notice { page = page.push( notice_bar( chooser, notice ) ); } + if let Some( name ) = &chooser.new_folder { page = page.push( folder_bar( chooser, name ) ); } + if let Some( target ) = &chooser.replacing + { + page = page.push( replace_bar( chooser, target ) ); + } else if chooser.purpose == Purpose::Save { + page = page.push( save_bar( chooser ) ); + } + container( page ).background( palette.bg ).into() +} + +fn header( chooser: &FileChooser ) -> Element +{ + let palette = crate::theme::palette(); + let title = match chooser.purpose + { + Purpose::Open => t!( "file_chooser.open" ), + Purpose::Save => t!( "file_chooser.save" ), + }; + let mut bar = row().padding( Length::widget( 4.0 ) ).spacing( Length::widget( 4.0 ) ); + if chooser.compact + { + bar = bar.push( tool( chooser, PLACES, t!( "file_chooser.places" ).to_string(), Some( FileChooserMsg::TogglePlaces ) ) ); + } + let titles = column() + .padding( 0.0 ) + .spacing( 0.0 ) + .align_center_x( false ) + .push( text( title.to_string() ).size( 15.0 ).weight( 600 ).color( palette.text_primary ) ) + .push( text( chooser.dir.display().to_string() ).size( 12.0 ).color( palette.text_secondary ) ); + bar = bar + .push( tool( chooser, UP, t!( "file_chooser.up" ).to_string(), chooser.parent().map( |_| FileChooserMsg::Up ) ) ) + .push( flex( titles ) ) + .push( tool( chooser, sort_icon( chooser.sort.key ), t!( "file_chooser.sort" ).to_string(), Some( FileChooserMsg::ToggleSort ) ) ); + if chooser.purpose == Purpose::Save + { + bar = bar.push( tool( chooser, NEW_FOLDER, t!( "file_chooser.new_folder" ).to_string(), Some( FileChooserMsg::NewFolder ) ) ); + } + bar.push( tool( chooser, CLOSE, t!( "file_chooser.cancel" ).to_string(), Some( FileChooserMsg::Cancel ) ) ).into() +} + +/// The places beside the folder, or on a narrow screen one of them. +fn body( chooser: &FileChooser ) -> Element +{ + if chooser.compact + { + return if chooser.places_open { place_list( chooser ) } else { listing( chooser ) }; + } + let side = container( place_list( chooser ) ).background( crate::theme::palette().surface_alt ); + row() + .spacing( 0.0 ) + .fill_height() + .push( flex( side ).weight( 1 ) ) + .push( flex( listing( chooser ) ).weight( 3 ) ) + .into() +} + +fn place_list( chooser: &FileChooser ) -> Element +{ + let palette = crate::theme::palette(); + let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 ); + for place in places::standard() + { + let selected = chooser.dir == place.path; + let label = place_label( &place ); + let glyph = place_icon( place.kind ); + list = list.push( entry( chooser, label, glyph, palette.icon, selected, None, None, FileChooserMsg::Enter( place.path ) ) ); + } + if crate::files::mounts().is_some() + { + list = list.push( entry( chooser, t!( "file_chooser.mount" ).to_string(), NEW_FOLDER, palette.icon, false, None, None, FileChooserMsg::Mount ) ); + } + scroll( list ).into() +} + +fn listing( chooser: &FileChooser ) -> Element +{ + let palette = crate::theme::palette(); + let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 ); + if chooser.loading + { + list = list.push( note( "…".to_string() ) ); + } else if let Some( error ) = &chooser.error { + list = list.push( note( error.clone() ) ); + } else if chooser.items.is_empty() { + list = list.push( note( t!( "file_chooser.empty" ).to_string() ) ); + } + let chosen = chooser.name.trim(); + for item in &chooser.items + { + let ( msg, tint ) = if item.is_dir + { + ( FileChooserMsg::Enter( item.path.clone() ), palette.accent ) + } else { + ( FileChooserMsg::Pick( item.path.clone() ), palette.icon ) + }; + let time = if chooser.sort.key == SortKey::Accessed { item.accessed } else { item.modified }; + let when = time.map( date ); + let weight = ( !item.is_dir ).then( || size( item.size ) ); + let ( below, beside ) = if chooser.compact + { + let both = [ weight, when ].into_iter().flatten().collect::>().join( " · " ); + ( ( !both.is_empty() ).then_some( both ), None ) + } else { + ( when, weight ) + }; + let selected = chooser.purpose == Purpose::Save && !item.is_dir && item.name == chosen; + list = list.push( entry( chooser, item.name.clone(), item_icon( item ), tint, selected, below, beside, msg ) ); + } + scroll( list ).into() +} + +#[ allow( clippy::too_many_arguments ) ] +fn entry( chooser: &FileChooser, label: String, glyph: &'static str, tint: Color, selected: bool, below: Option, beside: Option, msg: FileChooserMsg ) -> Element +{ + let palette = crate::theme::palette(); + let mut item = list_item( label ) + .height( Length::widget( if below.is_some() { ROW_H_SUB } else { ROW_H } ) ) + .ink( palette.text_primary, palette.text_secondary ) + .selected( selected ) + .on_press( chooser.msg( msg ) ); + if let Some( below ) = below { item = item.subtitle( below ); } + if let Some( beside ) = beside { item = item.trailing( beside ); } + if let Some( ( rgba, w, h ) ) = icon( glyph, tint ) { item = item.icon( rgba, w, h ); } + item.into() +} + +fn tool( chooser: &FileChooser, glyph: &'static str, label: String, msg: Option ) -> Element +{ + let palette = crate::theme::palette(); + let tint = if msg.is_some() { palette.icon } else { palette.divider }; + tinted_tool( chooser, glyph, label, msg, tint ) +} + +fn tinted_tool( chooser: &FileChooser, glyph: &'static str, label: String, msg: Option, tint: Color ) -> Element +{ + let base = match icon( glyph, tint ) + { + Some( ( rgba, w, h ) ) => icon_button( rgba, w, h ).icon_size( Length::widget( TOOL_ICON ) ), + None => button( label.clone() ), + }; + base.variant( ButtonVariant::Tertiary ) + .tooltip( label ) + .width( Length::widget( TOOL ) ) + .height( Length::widget( TOOL ) ) + .on_press_maybe( msg.map( |msg| chooser.msg( msg ) ) ) + .into() +} + +/// Each key, the one in use marked, with its arrows, the direction it sorts in marked in the accent. +fn sort_bar( chooser: &FileChooser ) -> Element +{ + let palette = crate::theme::palette(); + let sort = chooser.sort; + let mut list = column().padding( Length::widget( 4.0 ) ).spacing( 0.0 ); + for key in [ SortKey::Name, SortKey::Accessed, SortKey::Size ] + { + let descending = sort.directions.descending( key ); + let label = match key + { + SortKey::Name => t!( "file_chooser.sort_name" ), + SortKey::Accessed => t!( "file_chooser.sort_accessed" ), + SortKey::Size => t!( "file_chooser.sort_size" ), + }.to_string(); + let arrow = |glyph: &'static str, down: bool, tooltip: String| + { + let tint = if descending == down { palette.accent } else { palette.icon }; + tinted_tool( chooser, glyph, tooltip, Some( FileChooserMsg::SetSort( sort.with_direction( key, down ) ) ), tint ) + }; + let choice = entry( chooser, label, sort_icon( key ), palette.icon, sort.key == key, None, None, FileChooserMsg::SetSort( sort.select( key ) ) ); + list = list.push( row() + .spacing( 0.0 ) + .push( flex( choice ) ) + .push( arrow( ASCENDING, false, t!( "file_chooser.ascending" ).to_string() ) ) + .push( arrow( DESCENDING, true, t!( "file_chooser.descending" ).to_string() ) ) ); + } + list.into() +} + +fn save_bar( chooser: &FileChooser ) -> Element +{ + let wrap = Arc::clone( &chooser.wrap ); + let name = text_edit( t!( "file_chooser.name" ).to_string(), chooser.name.clone() ) + .id( NAME ) + .on_change( move |name| wrap( FileChooserMsg::Name( name ) ) ) + .on_submit( chooser.msg( FileChooserMsg::Confirm ) ); + let save = button( t!( "file_chooser.save_button" ).to_string() ) + .variant( ButtonVariant::Primary ) + .on_press_maybe( chooser.target().map( |_| chooser.msg( FileChooserMsg::Confirm ) ) ); + row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ).push( flex( name ) ).push( save ).into() +} + +fn folder_bar( chooser: &FileChooser, name: &str ) -> Element +{ + let wrap = Arc::clone( &chooser.wrap ); + let field = text_edit( t!( "file_chooser.folder_name" ).to_string(), name.to_string() ) + .id( FOLDER_NAME ) + .on_change( move |name| wrap( FileChooserMsg::FolderName( name ) ) ) + .on_submit( chooser.msg( FileChooserMsg::CreateFolder ) ); + let create = button( t!( "file_chooser.create" ).to_string() ) + .variant( ButtonVariant::Secondary ) + .on_press_maybe( chooser.new_folder_path().map( |_| chooser.msg( FileChooserMsg::CreateFolder ) ) ); + row() + .padding( Length::widget( 8.0 ) ) + .spacing( Length::widget( 8.0 ) ) + .push( flex( field ) ) + .push( create ) + .push( tool( chooser, CLOSE, t!( "file_chooser.cancel" ).to_string(), Some( FileChooserMsg::CancelFolder ) ) ) + .into() +} + +fn replace_bar( chooser: &FileChooser, target: &Path ) -> Element +{ + let palette = crate::theme::palette(); + let name = target.file_name().map( |name| name.to_string_lossy().into_owned() ).unwrap_or_default(); + let question = text( t!( "file_chooser.replace_question", name = name ).to_string() ).size( 14.0 ).color( palette.text_primary ).wrap( true ); + let keep = button( t!( "file_chooser.keep" ).to_string() ).variant( ButtonVariant::Secondary ).on_press( chooser.msg( FileChooserMsg::KeepFile ) ); + let replace = button( t!( "file_chooser.replace" ).to_string() ).variant( ButtonVariant::Primary ).on_press( chooser.msg( FileChooserMsg::Replace ) ); + row().padding( Length::widget( 8.0 ) ).spacing( Length::widget( 8.0 ) ).push( flex( question ) ).push( keep ).push( replace ).into() +} + +fn notice_bar( chooser: &FileChooser, notice: &str ) -> Element +{ + let palette = crate::theme::palette(); + let bar = row() + .padding( Length::widget( 4.0 ) ) + .push( flex( text( notice.to_string() ).size( 13.0 ).color( palette.text_primary ).wrap( true ) ) ) + .push( tool( chooser, CLOSE, t!( "file_chooser.close" ).to_string(), Some( FileChooserMsg::CloseNotice ) ) ); + container( bar ).background( palette.surface_alt ).into() +} + +fn note( message: String ) -> Element +{ + container( text( message ).size( 13.0 ).color( crate::theme::palette().text_secondary ).wrap( true ) ).padding( Length::widget( 12.0 ) ).into() +} + +fn sort_icon( key: SortKey ) -> &'static str +{ + match key + { + SortKey::Name => "archives/document-text", + SortKey::Accessed => "features/clock", + SortKey::Size => "hardware/harddisk", + } +} + +fn date( time: SystemTime ) -> String +{ + chrono::DateTime::::from( time ).format( "%Y-%m-%d %H:%M" ).to_string() +} + +fn place_label( place: &Place ) -> String +{ + if let Some( name ) = &place.name { return name.clone(); } + match place.kind + { + PlaceKind::Home => t!( "file_chooser.home" ), + PlaceKind::Desktop => t!( "file_chooser.desktop" ), + PlaceKind::Documents => t!( "file_chooser.documents" ), + PlaceKind::Downloads => t!( "file_chooser.downloads" ), + PlaceKind::Music => t!( "file_chooser.music" ), + PlaceKind::Pictures => t!( "file_chooser.pictures" ), + PlaceKind::Videos => t!( "file_chooser.videos" ), + PlaceKind::Root => t!( "file_chooser.root" ), + PlaceKind::Mount => t!( "file_chooser.folder" ), + }.to_string() +} + +fn place_icon( kind: PlaceKind ) -> &'static str +{ + match kind + { + PlaceKind::Home => "navigation/home", + PlaceKind::Desktop => "hardware/external-monitor", + PlaceKind::Documents => "archives/document-text", + PlaceKind::Downloads => "archives/folder-downloads", + PlaceKind::Music => "archives/folder-music", + PlaceKind::Pictures => "archives/folder-photos", + PlaceKind::Videos => "archives/folder-videos", + PlaceKind::Root => "hardware/harddisk", + PlaceKind::Mount => FOLDER, + } +} + +fn item_icon( item: &Item ) -> &'static str +{ + if item.is_dir { return FOLDER; } + let mime = item.mime.unwrap_or( "" ); + let ( top, sub ) = mime.split_once( '/' ).unwrap_or( ( mime, "" ) ); + match top + { + "image" => "archives/image", + "video" => "archives/video", + "audio" => "archives/audio", + "text" => "archives/document-text", + _ if sub == "pdf" => "archives/pdf", + _ if sub.contains( "zip" ) || sub.contains( "tar" ) || sub.contains( "compressed" ) => "archives/compressed-file", + _ => "archives/document", + } +} + +/// In bytes up to a kilobyte, then in the largest unit under 1024 of it. +pub( crate ) fn size( bytes: u64 ) -> String +{ + const UNITS: [ &str; 4 ] = [ "KB", "MB", "GB", "TB" ]; + if bytes < 1024 { return format!( "{bytes} B" ); } + let mut value = bytes as f64 / 1024.0; + let mut unit = 0; + while value >= 1024.0 && unit + 1 < UNITS.len() + { + value /= 1024.0; + unit += 1; + } + if value < 10.0 { format!( "{value:.1} {}", UNITS[ unit ] ) } else { format!( "{value:.0} {}", UNITS[ unit ] ) } +} diff --git a/src/files/callback.rs b/src/files/callback.rs new file mode 100644 index 0000000..e1ee821 --- /dev/null +++ b/src/files/callback.rs @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The operations of [`crate::files`] with a function in place of a message, for code with no run loop's [`crate::ChannelSender`] to take one, such as a session daemon: `done` is called on ltk's worker thread natively and on the page's thread in a browser. + +use std::io; +use std::path::PathBuf; + +use super::{ backend, Entry, Metadata, Mount, Operation }; + +pub fn list( path: impl Into, done: impl FnOnce( io::Result> ) + Send + 'static ) +{ + backend::list( path.into(), Box::new( done ) ); +} + +pub fn stat( path: impl Into, done: impl FnOnce( io::Result ) + Send + 'static ) +{ + backend::stat( path.into(), Box::new( done ) ); +} + +pub fn read( path: impl Into, done: impl FnOnce( io::Result> ) + Send + 'static ) +{ + backend::read( path.into(), None, Box::new( done ) ); +} + +pub fn read_range( path: impl Into, offset: u64, length: u64, done: impl FnOnce( io::Result> ) + Send + 'static ) +{ + backend::read( path.into(), Some( ( offset, length ) ), Box::new( done ) ); +} + +pub fn write( path: impl Into, data: Vec, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::write( path.into(), data, Box::new( done ) ); +} + +pub fn create_dir( path: impl Into, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::create_dir( path.into(), Box::new( done ) ); +} + +pub fn rename( from: impl Into, to: impl Into, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::rename( from.into(), to.into(), Box::new( done ) ); +} + +pub fn remove( path: impl Into, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::remove( path.into(), false, Box::new( done ) ); +} + +pub fn remove_all( path: impl Into, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::remove( path.into(), true, Box::new( done ) ); +} + +pub fn copy( from: impl Into, to: impl Into, progress: impl Fn( u64, u64 ) + Send + 'static, done: impl FnOnce( io::Result<()> ) + Send + 'static ) -> Operation +{ + Operation { handle: backend::copy( from.into(), to.into(), false, Box::new( progress ), Box::new( done ) ) } +} + +pub fn copy_all( from: impl Into, to: impl Into, progress: impl Fn( u64, u64 ) + Send + 'static, done: impl FnOnce( io::Result<()> ) + Send + 'static ) -> Operation +{ + Operation { handle: backend::copy( from.into(), to.into(), true, Box::new( progress ), Box::new( done ) ) } +} + +/// Have the browser ask the user for a folder of their computer and mount it, soon after the user's press; natively it fails with `Unsupported`. +pub fn mount( done: impl FnOnce( io::Result ) + Send + 'static ) +{ + backend::mount( Box::new( done ) ); +} + +pub fn unmount( path: impl Into, done: impl FnOnce( io::Result<()> ) + Send + 'static ) +{ + backend::unmount( path.into(), Box::new( done ) ); +} diff --git a/src/files/mod.rs b/src/files/mod.rs index 7844054..abc5f9b 100644 --- a/src/files/mod.rs +++ b/src/files/mod.rs @@ -9,6 +9,7 @@ //! //! An app's own resources are read through [`crate::vfs`], not here. +pub mod callback; #[ cfg( not( target_arch = "wasm32" ) ) ] mod copy; mod mime; @@ -190,6 +191,20 @@ pub fn unmount( path: impl Into, sender: &ChannelSen backend::unmount( path.into(), reply( sender, done ) ); } +static MOUNTS: std::sync::Mutex>> = std::sync::Mutex::new( None ); + +/// The folders mounted in a browser, as the desktop last said; `None` natively, where nothing is mounted, and before the desktop has said. +pub fn mounts() -> Option> +{ + MOUNTS.lock().ok()?.clone() +} + +#[ cfg_attr( not( feature = "web" ), allow( dead_code ) ) ] +pub( crate ) fn set_mounts( mounts: &[ Mount ] ) +{ + if let Ok( mut held ) = MOUNTS.lock() { *held = Some( mounts.to_vec() ); } +} + /// A file being written in chunks, whose contents appear only once closed; in a browser a new file shows empty until then. Dropped without [`Writer::close`], it is aborted and the file left as it was. pub struct Writer { diff --git a/src/input/keyboard/back.rs b/src/input/keyboard/back.rs index d39defa..d1d9262 100644 --- a/src/input/keyboard/back.rs +++ b/src/input/keyboard/back.rs @@ -83,7 +83,7 @@ impl AppData BackAtRoot::Minimize => self.platform.minimize_window( &self.main.platform ), BackAtRoot::Close => { - if self.app.on_close_requested() { self.exit_requested = true; } + self.request_close(); } BackAtRoot::Ignore => {} } diff --git a/src/input/keyboard/dispatch.rs b/src/input/keyboard/dispatch.rs index 6175f4d..c49ddfa 100644 --- a/src/input/keyboard/dispatch.rs +++ b/src/input/keyboard/dispatch.rs @@ -24,6 +24,7 @@ impl AppData Keysym::Left | Keysym::Right => self.handle_key_arrow_horizontal( focus, &event ), Keysym::Home => self.handle_key_home( focus ), Keysym::End => self.handle_key_end( focus ), + Keysym::Page_Up | Keysym::Page_Down => self.handle_key_page( focus, &event ), Keysym::a | Keysym::A if self.ctrl_pressed => self.handle_key_ctrl_a( focus ), Keysym::c | Keysym::C if self.ctrl_pressed => self.handle_key_ctrl_c( focus ), Keysym::x | Keysym::X if self.ctrl_pressed => self.handle_key_ctrl_x( focus ), diff --git a/src/input/keyboard/shortcuts.rs b/src/input/keyboard/shortcuts.rs index bea19b5..4719586 100644 --- a/src/input/keyboard/shortcuts.rs +++ b/src/input/keyboard/shortcuts.rs @@ -50,6 +50,17 @@ impl AppData pub( super ) fn handle_key_tab( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let reverse = event.keysym == Keysym::ISO_Left_Tab || self.shift_pressed; + if !reverse && !self.ctrl_pressed + { + let typed = self.surface( focus ).focused_idx + .and_then( |idx| find_handlers( &self.surface( focus ).frame.widget_rects, idx ) ) + .and_then( |h| h.tab_text().map( str::to_string ) ); + if let Some( text ) = typed + { + self.handle_text_insert( focus, &text ); + return; + } + } let intercepted = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ); if let Some( msg ) = intercepted { diff --git a/src/input/keyboard/text_keys.rs b/src/input/keyboard/text_keys.rs index a3963d3..40bd12d 100644 --- a/src/input/keyboard/text_keys.rs +++ b/src/input/keyboard/text_keys.rs @@ -138,6 +138,17 @@ impl AppData if self.focused_text_input( focus ) { self.handle_cursor_end( focus, self.shift_pressed ); } } + /// A page up or down in a multiline field; with Ctrl, or for any other widget, the app's. + pub( super ) fn handle_key_page( &mut self, focus: SurfaceFocus, event: &KeyInput ) + { + let up = event.keysym == Keysym::Page_Up; + if !self.ctrl_pressed && self.focused_text_input( focus ) && self.handle_cursor_page( focus, up, self.shift_pressed ) { return; } + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, self.ctrl_pressed, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } + } + pub( super ) fn handle_key_space( &mut self, focus: SurfaceFocus, event: &KeyInput ) { let ss = self.surface( focus ); @@ -165,11 +176,16 @@ impl AppData } } else if self.focused_text_input( focus ) { - if let Some( txt ) = event.text.clone() + let typed = event.text.clone().filter( |txt| !txt.is_empty() && txt.chars().all( |c| !c.is_control() ) ); + match typed { - if !txt.is_empty() && txt.chars().all( |c| !c.is_control() ) + Some( txt ) => self.handle_text_insert( focus, &txt ), + None => { - self.handle_text_insert( focus, &txt ); + if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed ) + { + self.pending_msgs.push( msg ); + } } } } else if let Some( msg ) = self.app.on_key_with_modifiers( event.keysym, false, self.shift_pressed ) diff --git a/src/input/pointer/motion.rs b/src/input/pointer/motion.rs index 50e7b66..85744c6 100644 --- a/src/input/pointer/motion.rs +++ b/src/input/pointer/motion.rs @@ -125,7 +125,10 @@ impl AppData } ); if let Some( idx ) = pressed_text { - self.handle_text_pointer_drag( focus, idx, pp ); + if !self.handle_text_scrollbar_drag( focus, idx, pp ) + { + self.handle_text_pointer_drag( focus, idx, pp ); + } } // Cursor shape: hover may have changed → push the // new shape to the compositor (no-op when diff --git a/src/input/pointer/press.rs b/src/input/pointer/press.rs index aa919cc..3eaff67 100644 --- a/src/input/pointer/press.rs +++ b/src/input/pointer/press.rs @@ -104,10 +104,7 @@ impl AppData let close_rect = self.surface( focus ).titlebar_close_rect; if close_rect.contains( pos ) { - if self.app.on_close_requested() - { - self.exit_requested = true; - } + self.request_close(); return; } } @@ -199,7 +196,7 @@ impl AppData // short-circuits the text-edit // dispatch — fire the toggle msg and // skip cursor placement. - if self.handle_password_toggle_press( focus, idx, pos ) + if self.handle_password_toggle_press( focus, idx, pos ) || self.handle_text_scrollbar_press( focus, idx, pos ) { let _ = self.note_press_for_double_click( pos ); } diff --git a/src/input/touch/mod.rs b/src/input/touch/mod.rs index 10aaef5..73e4e93 100644 --- a/src/input/touch/mod.rs +++ b/src/input/touch/mod.rs @@ -186,7 +186,7 @@ impl AppData // Eye icon hit on a password field short-circuits // the text-edit dispatch — fire the toggle msg and // skip cursor placement. - if self.handle_password_toggle_press( focus, idx, pos ) + if self.handle_password_toggle_press( focus, idx, pos ) || self.handle_text_scrollbar_press( focus, idx, pos ) { let _ = self.note_press_for_double_click( pos ); } @@ -301,7 +301,10 @@ impl AppData } ); if let Some( idx ) = pressed_text { - self.handle_text_pointer_drag( focus, idx, pp ); + if !self.handle_text_scrollbar_drag( focus, idx, pp ) + { + self.handle_text_pointer_drag( focus, idx, pp ); + } } } diff --git a/src/layout/stack.rs b/src/layout/stack.rs index c91e235..504c1cf 100644 --- a/src/layout/stack.rs +++ b/src/layout/stack.rs @@ -196,7 +196,9 @@ impl Stack let inner_w = ( rect.width - margin * 2.0 ).max( 0.0 ); let inner_h = ( rect.height - margin * 2.0 ).max( 0.0 ); + // A child larger than the stack gets the stack, rather than running out of the side away from its alignment. let ( pref_w, pref_h ) = child.preferred_size( inner_w, canvas ); + let ( pref_w, pref_h ) = ( pref_w.min( inner_w ), pref_h.min( inner_h ) ); let ( x, width ) = match h_align { diff --git a/src/lib.rs b/src/lib.rs index 3558d50..f76a4d3 100755 --- a/src/lib.rs +++ b/src/lib.rs @@ -322,6 +322,7 @@ pub( crate ) mod resources; pub mod time; pub mod vfs; pub mod files; +pub mod file_chooser; pub mod gles_render; #[ cfg( not( target_arch = "wasm32" ) ) ] pub mod egl_context; @@ -331,7 +332,7 @@ pub use app:: { Anchor, App, BackAtRoot, BackResponse, ChannelSender, ShellMode, Layer, OverlayId, OverlaySpec, SubsurfaceId, SubsurfaceSpec, SubsurfaceParent, - InvalidationScope, MouseButton, MouseButtonEvent, RawKey, SelectionKind, SurfaceTarget, ToplevelEvent, + InvalidationScope, MouseButton, MouseButtonEvent, RawKey, SelectionKind, SurfaceTarget, TextCommand, ToplevelEvent, }; pub use theme:: { @@ -385,6 +386,7 @@ pub use widget::slider::{ Slider, slider, SliderAxis }; pub use widget::vslider::{ VSlider, vslider }; pub use widget::pattern_grid::{ PatternGrid, pattern_grid }; pub use widget::text::{ TextAlign, WRAP_PAD as TEXT_WRAP_PAD }; +pub use widget::text_edit::Highlight as TextHighlight; pub use widget::toggle::{ Toggle, toggle }; pub use widget::separator::{ Separator, separator }; pub use widget::progress_bar::{ ProgressBar, progress_bar }; @@ -415,6 +417,7 @@ pub use widget::color_picker:: ColorPicker, color_picker, color_to_hex, parse_hex, }; pub use widget::dialog::{ Dialog, dialog }; +pub use file_chooser::{ Chosen, FileChooser, FileChooserMsg }; pub use layout::spacer::{ Spacer, spacer }; pub use layout::column::{ Column, column }; pub use layout::row::{ Row, row }; @@ -436,6 +439,7 @@ pub mod web pub use crate::platform::web::{ load_resources, load_tree, set_canvas, set_shell_listener, shell_request, shell_send }; } pub use resources::{ resource_dir, set_resource_dir }; +pub use system_fonts::monospace as monospace_font; pub use xkeysym::Keysym; /// Widgets — the interactive and decorative leaves of the [`Element`] diff --git a/src/platform/wayland/handlers.rs b/src/platform/wayland/handlers.rs index e18bddb..a808126 100644 --- a/src/platform/wayland/handlers.rs +++ b/src/platform/wayland/handlers.rs @@ -185,10 +185,7 @@ impl LayerShellHandler for AppData { Some( SurfaceFocus::Main ) | None => { - if self.app.on_close_requested() - { - self.exit_requested = true; - } + self.request_close(); } Some( SurfaceFocus::Overlay( id ) ) => { @@ -261,10 +258,7 @@ impl WindowHandler for AppData } return; } - if self.app.on_close_requested() - { - self.exit_requested = true; - } + self.request_close(); } fn configure( diff --git a/src/platform/wayland/run.rs b/src/platform/wayland/run.rs index ec9f30e..fb2d089 100644 --- a/src/platform/wayland/run.rs +++ b/src/platform/wayland/run.rs @@ -192,7 +192,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> } ShellMode::Window => { let xdg = bind_xdg( &globals, &qh )?; - let window = make_window( &xdg, "ltk", &mut session, true ); + let window = make_window( &xdg, &app.window_title(), &mut session, true ); ( SurfaceKind::Window( window ), Some( xdg ) ) } ShellMode::Layer( layer ) => { @@ -213,7 +213,7 @@ pub( crate ) fn try_run( mut app: A ) -> Result<(), RunError> } else { eprintln!( "ltk: wlr-layer-shell not available, falling back to xdg window" ); let xdg = bind_xdg( &globals, &qh )?; - let window = make_window( &xdg, "ltk", &mut session, false ); + let window = make_window( &xdg, &app.window_title(), &mut session, false ); ( SurfaceKind::Window( window ), Some( xdg ) ) } } diff --git a/src/platform/web/desktop.rs b/src/platform/web/desktop.rs index 0e42ac0..297bb48 100644 --- a/src/platform/web/desktop.rs +++ b/src/platform/web/desktop.rs @@ -156,7 +156,7 @@ pub( super ) fn main_role( app: &A ) -> Value let window = || { let ( width, height ) = app.window_size_hint().unwrap_or( ( 0, 0 ) ); - json!( { "kind": "window", "title": app.app_id(), "app_id": app.app_id(), "width": width, "height": height } ) + json!( { "kind": "window", "title": app.window_title(), "app_id": app.app_id(), "width": width, "height": height } ) }; match app.shell_mode() { diff --git a/src/platform/web/run.rs b/src/platform/web/run.rs index dac5078..576e92f 100644 --- a/src/platform/web/run.rs +++ b/src/platform/web/run.rs @@ -301,7 +301,7 @@ impl AppData PageEvent::Configure { surface, width, height, scale } => self.configure_overlay( surface, width, height, scale ), PageEvent::Close { surface: MAIN } => { - if self.app.on_close_requested() { self.exit_requested = true; } + self.request_close(); } PageEvent::Close { surface } => self.dismiss_overlay( surface ), PageEvent::Toplevel( event ) => @@ -314,6 +314,7 @@ impl AppData } PageEvent::Mounts( mounts ) => { + crate::files::set_mounts( &mounts ); if let Some( msg ) = self.app.on_mounts_changed( &mounts ) { self.pending_msgs.push( msg ); } } PageEvent::OpenFiles( paths ) => diff --git a/src/render/mod.rs b/src/render/mod.rs index 90039e9..0ac1cb3 100644 --- a/src/render/mod.rs +++ b/src/render/mod.rs @@ -941,6 +941,28 @@ impl Canvas } } + /// The advance of `ch` alone, as [`Self::measure_text`] gives it, kept so that a layout measuring a long text character by character does not shape each one again. + pub fn measure_char( &self, ch: char, size: f32 ) -> f32 + { + let font_ctx = self.font() as *const FontFace as usize; + crate::text_shaping::char_advance_cached( font_ctx, size * self.dpi_scale(), ch, || + { + let mut buf = [ 0u8; 4 ]; + self.measure_text( ch.encode_utf8( &mut buf ), size ) + } ) + } + + /// [`Self::measure_char`] in `font` instead of the canvas's. + pub fn measure_char_with_font( &self, ch: char, size: f32, font: &Arc ) -> f32 + { + let font_ctx = Arc::as_ptr( font ) as usize; + crate::text_shaping::char_advance_cached( font_ctx, size * self.dpi_scale(), ch, || + { + let mut buf = [ 0u8; 4 ]; + self.measure_text_with_font( ch.encode_utf8( &mut buf ), size, font ) + } ) + } + /// Width of `text` rendered with `font`. Mirrors /// [`Self::measure_text`] but bypasses the canvas default font so /// text laid out at one weight and drawn at another stays aligned. diff --git a/src/system_fonts.rs b/src/system_fonts.rs index 4bf5b28..10b0946 100644 --- a/src/system_fonts.rs +++ b/src/system_fonts.rs @@ -282,3 +282,38 @@ pub fn lookup_handle( ch: char ) -> Option .find( |&idx| slot_covers( idx, ch ) ) .and_then( slot_handle ) } + +/// The monospace face, as found among the app's resources, which a page and an APK carry, and then among the system's fonts. +const MONOSPACE_BUNDLED: &str = "DejaVuSansMono.ttf"; + +#[ cfg( not( target_os = "android" ) ) ] +const MONOSPACE_CANDIDATES: &[&str] = +&[ + "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/TTF/DejaVuSansMono.ttf", + "/usr/share/fonts/dejavu/DejaVuSansMono.ttf", + "/usr/share/fonts/truetype/hack/Hack-Regular.ttf", + "/usr/share/fonts/truetype/noto/NotoSansMono-Regular.ttf", +]; + +#[ cfg( target_os = "android" ) ] +const MONOSPACE_CANDIDATES: &[&str] = +&[ + "/system/fonts/DroidSansMono.ttf", + "/system/fonts/CutiveMono.ttf", +]; + +/// A monospace face for text that lines up in columns, such as line numbers or a terminal; `None` when none is installed. +pub fn monospace() -> Option> +{ + static FACE: OnceLock>> = OnceLock::new(); + FACE.get_or_init( || + { + let bundled = crate::resource_dir().map( |dir| dir.join( "fonts" ).join( MONOSPACE_BUNDLED ) ); + let path = bundled.into_iter() + .chain( MONOSPACE_CANDIDATES.iter().map( std::path::PathBuf::from ) ) + .find( |path| crate::vfs::is_file( path ) )?; + let bytes = crate::vfs::read( path ).ok()?; + FontFace::from_bytes( Arc::new( bytes ), 0 ).map( Arc::new ) + } ).clone() +} diff --git a/src/text_shaping.rs b/src/text_shaping.rs index c0f3229..65dc67e 100644 --- a/src/text_shaping.rs +++ b/src/text_shaping.rs @@ -147,6 +147,10 @@ thread_local! static SHAPE_CACHE: std::cell::RefCell< std::collections::HashMap<( usize, u32, String ), std::sync::Arc>> > = std::cell::RefCell::new( std::collections::HashMap::new() ); + + static CHAR_ADVANCE: std::cell::RefCell< + std::collections::HashMap<( usize, u32, char ), f32> + > = std::cell::RefCell::new( std::collections::HashMap::new() ); } /// Soft cap on cached shaped lines; cleared wholesale on overflow (the live @@ -277,6 +281,25 @@ where pub fn clear_shape_cache() { SHAPE_CACHE.with( |c| c.borrow_mut().clear() ); + CHAR_ADVANCE.with( |c| c.borrow_mut().clear() ); +} + +/// The advance of `ch` at `px` with the font context `font_ctx`, measured by `measure` the first time it is asked for. +pub( crate ) fn char_advance_cached( font_ctx: usize, px: f32, ch: char, measure: impl FnOnce() -> f32 ) -> f32 +{ + let key = ( font_ctx, px.to_bits(), ch ); + if let Some( w ) = CHAR_ADVANCE.with( |c| c.borrow().get( &key ).copied() ) + { + return w; + } + let w = measure(); + CHAR_ADVANCE.with( |c| + { + let mut c = c.borrow_mut(); + if c.len() >= SHAPE_CACHE_CAP { c.clear(); } + c.insert( key, w ); + } ); + w } /// Shape a text run using rustybuzz (HarfBuzz-compatible shaping) and diff --git a/src/widget/element.rs b/src/widget/element.rs index 5d59d11..0800204 100644 --- a/src/widget/element.rs +++ b/src/widget/element.rs @@ -99,12 +99,12 @@ impl Element cursor_pos: usize, selection_anchor: usize, text_scroll: text_edit::TextScroll, - ) + ) -> Option { match self { Element::Button( b ) => b.draw( canvas, rect, focused, hovered, pressed ), - Element::TextEdit( t ) => t.draw( canvas, rect, focused, cursor_pos, selection_anchor, text_scroll ), + Element::TextEdit( t ) => return t.draw( canvas, rect, focused, cursor_pos, selection_anchor, text_scroll ), Element::Image( i ) => i.draw( canvas, rect ), Element::Column( c ) => c.draw( canvas, rect, focused ), Element::Row( r ) => r.draw( canvas, rect, focused ), @@ -136,6 +136,7 @@ impl Element Element::TabCells( _ ) => {} Element::TabCards( _ ) => {} } + None } pub fn hit_test( &self, rect: Rect, pos: Point ) -> bool @@ -323,6 +324,7 @@ impl Element { value: t.value.clone(), on_change: t.on_change.clone(), + on_cursor: t.on_cursor.clone(), on_submit: t.on_submit.clone(), on_focus: t.on_focus.clone(), // `secure` here drives memory wipe-on-drop and @@ -338,6 +340,9 @@ impl Element select_on_focus: t.select_on_focus, pin_cursor_to_end: t.pin_cursor_to_end, password_toggle_msg: t.password_toggle.as_ref().map( |( _, m )| m.clone() ), + tab_text: t.tab_text.clone(), + line_numbers: t.line_numbers && t.is_multiline(), + face: t.text_font().face, } } Element::PatternGrid( g ) => diff --git a/src/widget/handlers.rs b/src/widget/handlers.rs index 79a87d6..432cea7 100644 --- a/src/widget/handlers.rs +++ b/src/widget/handlers.rs @@ -39,6 +39,7 @@ pub enum WidgetHandlers { value: String, on_change: Option Msg>>, + on_cursor: Option Msg>>, on_submit: Option, on_focus: Option, /// `true` when the source `TextEdit` was built with @@ -78,6 +79,12 @@ pub enum WidgetHandlers /// before falling through to cursor placement and fires /// this message instead. password_toggle_msg: Option, + /// What Tab types in the field, from [`crate::widget::text_edit::TextEdit::tab_text`]. + tab_text: Option, + /// Whether a multiline field shows line numbers, whose column its text starts after. + line_numbers: bool, + /// The face of a multiline field's text, from [`crate::widget::text_edit::TextEdit::font`]. + face: Option>, }, Slider { @@ -146,12 +153,13 @@ impl Clone for WidgetHandlers WidgetHandlers::Radio { on_select, selected } => WidgetHandlers::Radio { on_select: on_select.clone(), selected: *selected }, WidgetHandlers::ListItem { on_press } => WidgetHandlers::ListItem { on_press: on_press.clone() }, WidgetHandlers::WindowButton { on_press } => WidgetHandlers::WindowButton { on_press: on_press.clone() }, - WidgetHandlers::TextEdit { value, on_change, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, pin_cursor_to_end, password_toggle_msg } => + WidgetHandlers::TextEdit { value, on_change, on_cursor, on_submit, on_focus, secure, purpose, multiline, align, font_size, pad_h, select_on_focus, pin_cursor_to_end, password_toggle_msg, tab_text, line_numbers, face } => { WidgetHandlers::TextEdit { value: value.clone(), on_change: on_change.clone(), + on_cursor: on_cursor.clone(), on_submit: on_submit.clone(), on_focus: on_focus.clone(), secure: *secure, @@ -163,6 +171,9 @@ impl Clone for WidgetHandlers select_on_focus: *select_on_focus, pin_cursor_to_end: *pin_cursor_to_end, password_toggle_msg: password_toggle_msg.clone(), + tab_text: tab_text.clone(), + line_numbers: *line_numbers, + face: face.clone(), } } WidgetHandlers::Slider { on_change, on_release, axis, value, thumb_px } => @@ -455,4 +466,24 @@ impl WidgetHandlers _ => None, } } + + /// The message a text field that asked for its cursor sends with the cursor at `at`. + pub fn text_cursor_msg( &self, at: usize ) -> Option + { + match self + { + WidgetHandlers::TextEdit { on_cursor: Some( f ), .. } => Some( f( at ) ), + _ => None, + } + } + + /// What Tab types in a text field that asked for it. + pub fn tab_text( &self ) -> Option<&str> + { + match self + { + WidgetHandlers::TextEdit { tab_text, .. } => tab_text.as_deref(), + _ => None, + } + } } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index dd7e94b..80c8f89 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -8,7 +8,7 @@ use std::sync::Arc; use crate::font_face::FontFace; - +use crate::layout::stack::VAlign; use crate::theme::FontStyle; use crate::types::{ Color, Length, Rect }; use crate::render::Canvas; @@ -57,6 +57,11 @@ pub struct Text /// onto multiple lines. `1.0` (default) uses the font-declared leading; /// values above `1.0` open up the gap between wrapped lines. pub( crate ) line_height: f32, + /// Drawn at the largest size up to [`Self::size`] at which it fits its + /// layout rect, instead of overflowing it. + pub( crate ) fit: bool, + /// Where the lines sit in a rect taller than they are. + pub( crate ) valign: VAlign, } impl Text @@ -76,6 +81,8 @@ impl Text font: None, face: None, line_height: 1.0, + fit: false, + valign: VAlign::Top, } } @@ -171,6 +178,27 @@ impl Text self } + /// Draw at the largest size, up to [`Self::size`], at which the text + /// fits the rect the layout gives it: on one line within its width and + /// height, or, with [`Self::wrap`], all its lines within its height. + /// For text whose length varies, such as a number, a total or a title, + /// in a box whose size the window sets. The preferred size is still + /// the one at [`Self::size`]. + pub fn fit( mut self ) -> Self + { + self.fit = true; + self + } + + /// Where the lines sit in a rect taller than they are, as a fitted text's + /// may be: at its top (the default), its centre or its bottom. + /// [`VAlign::Fill`] is the top. + pub fn valign( mut self, v: VAlign ) -> Self + { + self.valign = v; + self + } + fn resolve_font( &self, canvas: &Canvas ) -> Option> { if let Some( face ) = &self.face @@ -183,9 +211,8 @@ impl Text } ) } - fn measure( &self, text: &str, canvas: &Canvas, font: Option<&Arc> ) -> f32 + fn measure( &self, text: &str, size: f32, canvas: &Canvas, font: Option<&Arc> ) -> f32 { - let size = self.resolved_size( canvas ); match font { Some( f ) => canvas.measure_text_with_font( text, size, f ), @@ -193,9 +220,8 @@ impl Text } } - fn measure_char( &self, ch: char, canvas: &Canvas, font: Option<&Arc> ) -> f32 + fn measure_char( &self, ch: char, size: f32, canvas: &Canvas, font: Option<&Arc> ) -> f32 { - let size = self.resolved_size( canvas ); match font { Some( f ) => f.metrics( ch, size * canvas.dpi_scale() ).advance_width, @@ -203,9 +229,8 @@ impl Text } } - fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, font: Option<&Arc> ) + fn paint( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, size: f32, font: Option<&Arc> ) { - let size = self.resolved_size( canvas ); match font { Some( f ) => canvas.draw_text_with_font( text, x, y, size, self.color, f ), @@ -213,17 +238,22 @@ impl Text } } + /// `new_line_size = ascent - descent + line_gap` is the standard + /// typographic line height; `ascent - descent` alone discards the + /// font-declared leading and at large sizes leaves adjacent rows + /// visibly overlapping when stacked tight in a column. + fn line_height_at( &self, size: f32, canvas: &Canvas ) -> f32 + { + canvas.font_line_metrics( size ) + .map( |m| m.new_line_size ) + .unwrap_or( size ) * self.line_height + } + pub fn preferred_size( &self, max_width: f32, canvas: &Canvas ) -> ( f32, f32 ) { let size = self.resolved_size( canvas ); - // `new_line_size = ascent - descent + line_gap` is the standard - // typographic line height; the previous `ascent - descent` discarded - // the font-declared leading and at large sizes left adjacent rows - // visibly overlapping when stacked tight in a column. - let line_h = canvas.font_line_metrics( size ) - .map( |m| m.new_line_size ) - .unwrap_or( size ) * self.line_height; - let font = self.resolve_font( canvas ); + let line_h = self.line_height_at( size, canvas ); + let font = self.resolve_font( canvas ); if self.wrap { @@ -233,28 +263,65 @@ impl Text } else { - let w = ( self.measure( &self.content, canvas, font.as_ref() ) + 8.0 ).min( max_width ); + let w = ( self.measure( &self.content, size, canvas, font.as_ref() ) + 8.0 ).min( max_width ); ( w, line_h ) } } + /// [`Self::size`], or with [`Self::fit`] the largest size up to it at which the text fits `rect`. + fn fitted_size( &self, rect: Rect, canvas: &Canvas, font: Option<&Arc> ) -> f32 + { + let size = self.resolved_size( canvas ); + if !self.fit { return size; } + let fits = |s: f32| + { + let line_h = self.line_height_at( s, canvas ); + if self.wrap + { + wrap_lines( &self.content, s, rect.width - WRAP_PAD, canvas, font ).len() as f32 * line_h <= rect.height + } else { + self.measure( &self.content, s, canvas, font ) + 8.0 <= rect.width && line_h <= rect.height + } + }; + if fits( size ) { return size; } + // Bisection: wrapping makes the fit a step function of the size, so it cannot be solved for directly. + let ( mut lo, mut hi ) = ( 1.0_f32.min( size ), size ); + for _ in 0..12 + { + let mid = ( lo + hi ) / 2.0; + if fits( mid ) { lo = mid; } else { hi = mid; } + } + lo + } + + /// How far down `rect` lines `block` high start, as [`Self::valign`] says. + fn top( &self, rect: Rect, block: f32 ) -> f32 + { + let slack = ( rect.height - block ).max( 0.0 ); + match self.valign + { + VAlign::Top | VAlign::Fill => rect.y, + VAlign::Center => rect.y + slack / 2.0, + VAlign::Bottom => rect.y + slack, + } + } + pub fn draw( &self, canvas: &mut Canvas, rect: Rect, _focused: bool ) { - let size = self.resolved_size( canvas ); + let font = self.resolve_font( canvas ); + let size = self.fitted_size( rect, canvas, font.as_ref() ); let ascent = canvas.font_line_metrics( size ) .map( |m| m.ascent ) .unwrap_or( size * 0.8 ); - let line_h = canvas.font_line_metrics( size ) - .map( |m| m.new_line_size ) - .unwrap_or( size ) * self.line_height; - let font = self.resolve_font( canvas ); + let line_h = self.line_height_at( size, canvas ); if self.wrap { let lines = wrap_lines( &self.content, size, rect.width - WRAP_PAD, canvas, font.as_ref() ); + let top = self.top( rect, line_h * lines.len() as f32 ); for ( i, line ) in lines.iter().enumerate() { - let line_w = self.measure( line, canvas, font.as_ref() ); + let line_w = self.measure( line, size, canvas, font.as_ref() ); let slack = ( rect.width - line_w ).max( 0.0 ); let pad = WRAP_PAD.min( slack ); let tx = match self.align @@ -263,18 +330,18 @@ impl Text TextAlign::Center => rect.x + slack / 2.0, TextAlign::Right => rect.x + rect.width - line_w - pad, }; - let ty = rect.y + ascent + line_h * i as f32; - self.paint( canvas, line, tx, ty, font.as_ref() ); + let ty = top + ascent + line_h * i as f32; + self.paint( canvas, line, tx, ty, size, font.as_ref() ); } return; } - let text_w = self.measure( &self.content, canvas, font.as_ref() ); + let text_w = self.measure( &self.content, size, canvas, font.as_ref() ); let display = if self.truncate && text_w > rect.width && rect.width > 0.0 { let ellipsis = "..."; - let ell_w = self.measure( ellipsis, canvas, font.as_ref() ); + let ell_w = self.measure( ellipsis, size, canvas, font.as_ref() ); let budget = rect.width - ell_w; if budget <= 0.0 { @@ -285,7 +352,7 @@ impl Text let mut accum = 0.0_f32; let truncated: String = self.content.chars().take_while( |ch| { - let cw = self.measure_char( *ch, canvas, font.as_ref() ); + let cw = self.measure_char( *ch, size, canvas, font.as_ref() ); accum += cw; accum <= budget } ).collect(); @@ -297,7 +364,7 @@ impl Text self.content.clone() }; - let disp_w = self.measure( &display, canvas, font.as_ref() ); + let disp_w = self.measure( &display, size, canvas, font.as_ref() ); let slack = ( rect.width - disp_w ).max( 0.0 ); let pad = 4.0_f32.min( slack ); let tx = match self.align @@ -307,8 +374,8 @@ impl Text TextAlign::Right => rect.x + rect.width - disp_w - pad, }; - let ty = rect.y + ascent; - self.paint( canvas, &display, tx, ty, font.as_ref() ); + let ty = self.top( rect, line_h ) + ascent; + self.paint( canvas, &display, tx, ty, size, font.as_ref() ); } } diff --git a/src/widget/text/tests.rs b/src/widget/text/tests.rs index ebb6ac7..cf5b6aa 100644 --- a/src/widget/text/tests.rs +++ b/src/widget/text/tests.rs @@ -140,3 +140,60 @@ fn text_align_enum_implements_partial_eq() assert_eq!( TextAlign::Left, TextAlign::Left ); assert_ne!( TextAlign::Left, TextAlign::Right ); } + +fn rect( x: f32, y: f32, width: f32, height: f32 ) -> Rect +{ + Rect { x, y, width, height } +} + +#[ test ] +fn without_fit_the_text_keeps_its_size() +{ + let canvas = make_canvas(); + let t = Text::new( "0123456789" ).size( 64.0 ); + assert_eq!( t.fitted_size( rect( 0.0, 0.0, 40.0, 20.0 ), &canvas, None ), 64.0 ); +} + +#[ test ] +fn a_fitted_text_that_fits_keeps_its_size() +{ + let canvas = make_canvas(); + let t = Text::new( "0" ).size( 32.0 ).fit(); + assert_eq!( t.fitted_size( rect( 0.0, 0.0, 400.0, 300.0 ), &canvas, None ), 32.0 ); +} + +#[ test ] +fn a_fitted_line_shrinks_to_the_width_and_the_height() +{ + let canvas = make_canvas(); + let wide = Text::new( "0123456789" ).size( 64.0 ).fit(); + let size = wide.fitted_size( rect( 0.0, 0.0, 120.0, 300.0 ), &canvas, None ); + assert!( size < 64.0, "size={size}" ); + assert!( wide.measure( &wide.content, size, &canvas, None ) + 8.0 <= 120.0 ); + + let low = Text::new( "0" ).size( 64.0 ).fit(); + let size = low.fitted_size( rect( 0.0, 0.0, 400.0, 30.0 ), &canvas, None ); + assert!( size < 64.0 && low.line_height_at( size, &canvas ) <= 30.0, "size={size}" ); +} + +#[ test ] +fn a_fitted_wrapped_text_keeps_all_its_lines_within_the_height() +{ + let canvas = make_canvas(); + let t = Text::new( "the quick brown fox jumps over the lazy dog" ).size( 48.0 ).wrap( true ).fit(); + let area = rect( 0.0, 0.0, 160.0, 90.0 ); + let size = t.fitted_size( area, &canvas, None ); + let lines = wrap_lines( &t.content, size, area.width - WRAP_PAD, &canvas, None ); + assert!( size < 48.0 ); + assert!( lines.len() as f32 * t.line_height_at( size, &canvas ) <= area.height, "size={size} lines={lines:?}" ); +} + +#[ test ] +fn valign_places_the_lines_in_a_taller_rect() +{ + let area = rect( 0.0, 10.0, 100.0, 100.0 ); + assert_eq!( Text::new( "" ).top( area, 20.0 ), 10.0 ); + assert_eq!( Text::new( "" ).valign( VAlign::Center ).top( area, 20.0 ), 50.0 ); + assert_eq!( Text::new( "" ).valign( VAlign::Bottom ).top( area, 20.0 ), 90.0 ); + assert_eq!( Text::new( "" ).valign( VAlign::Bottom ).top( area, 150.0 ), 10.0 ); +} diff --git a/src/widget/text_edit/cursor.rs b/src/widget/text_edit/cursor.rs index 99d1c28..4495fe0 100644 --- a/src/widget/text_edit/cursor.rs +++ b/src/widget/text_edit/cursor.rs @@ -1,12 +1,17 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use std::rc::Rc; + use crate::render::Canvas; -use crate::types::{ Length, Rect }; +use crate::types::Rect; use super::hit_test::byte_offset_in_line; use super::metrics::MultilineMetrics; +use super::scroll::{ TextScroll, Window }; +use super::tabs::line_x; use super::wrapping::{ compute_visual_lines, VisualLine }; +use super::TextFont; /// Locate the visual line containing `cursor` in the wrap layout for /// `value` inside `rect`. When the cursor sits exactly on a soft-wrap @@ -17,13 +22,14 @@ use super::wrapping::{ compute_visual_lines, VisualLine }; /// edge of the new one. fn current_visual_line( canvas: &Canvas, + font: &TextFont, m: &MultilineMetrics, rect: Rect, value: &str, cursor: usize, -) -> Option<( Vec, usize )> +) -> Option<( Rc>, usize )> { - let visual_lines = compute_visual_lines( canvas, value, m.inner_width( rect ), m.font_size ); + let visual_lines = compute_visual_lines( canvas, font, value, m.inner_width( rect ), m.font_size ); if visual_lines.is_empty() { return None; } let safe_cursor = cursor.min( value.len() ); let idx = visual_lines.iter() @@ -47,17 +53,17 @@ pub( crate ) fn cursor_visual_up( rect: Rect, value: &str, cursor: usize, - font_size: Option, + font: &TextFont, ) -> Option { - let m = MultilineMetrics::resolve( canvas, font_size ); - let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?; + let m = MultilineMetrics::resolve( canvas, font.size ); + let ( lines, idx ) = current_visual_line( canvas, font, &m, rect, value, cursor )?; if idx == 0 { return None; } let cur = lines[ idx ]; let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], m.font_size ); + let target_x = line_x( canvas, font, value, cur.start, safe, m.font_size ); let prev = lines[ idx - 1 ]; - Some( byte_offset_in_line( canvas, value, prev.start, prev.end, target_x, false, m.font_size ) ) + Some( byte_offset_in_line( canvas, font, value, prev.start, prev.end, target_x, false, true, m.font_size ) ) } /// Mirror of [`cursor_visual_up`] for the Down arrow. @@ -66,17 +72,17 @@ pub( crate ) fn cursor_visual_down( rect: Rect, value: &str, cursor: usize, - font_size: Option, + font: &TextFont, ) -> Option { - let m = MultilineMetrics::resolve( canvas, font_size ); - let ( lines, idx ) = current_visual_line( canvas, &m, rect, value, cursor )?; + let m = MultilineMetrics::resolve( canvas, font.size ); + let ( lines, idx ) = current_visual_line( canvas, font, &m, rect, value, cursor )?; if idx + 1 >= lines.len() { return None; } let cur = lines[ idx ]; let safe = cursor.min( value.len() ); - let target_x = canvas.measure_text( &value[cur.start..safe], m.font_size ); + let target_x = line_x( canvas, font, value, cur.start, safe, m.font_size ); let next = lines[ idx + 1 ]; - Some( byte_offset_in_line( canvas, value, next.start, next.end, target_x, false, m.font_size ) ) + Some( byte_offset_in_line( canvas, font, value, next.start, next.end, target_x, false, true, m.font_size ) ) } /// Byte offset of the start of the cursor's current visual line. @@ -87,11 +93,11 @@ pub( crate ) fn cursor_visual_home( rect: Rect, value: &str, cursor: usize, - font_size: Option, + font: &TextFont, ) -> usize { - let m = MultilineMetrics::resolve( canvas, font_size ); - current_visual_line( canvas, &m, rect, value, cursor ) + let m = MultilineMetrics::resolve( canvas, font.size ); + current_visual_line( canvas, font, &m, rect, value, cursor ) .map( |( lines, idx )| lines[ idx ].start ) .unwrap_or( 0 ) } @@ -106,11 +112,44 @@ pub( crate ) fn cursor_visual_end( rect: Rect, value: &str, cursor: usize, - font_size: Option, + font: &TextFont, ) -> usize { - let m = MultilineMetrics::resolve( canvas, font_size ); - current_visual_line( canvas, &m, rect, value, cursor ) + let m = MultilineMetrics::resolve( canvas, font.size ); + current_visual_line( canvas, font, &m, rect, value, cursor ) .map( |( lines, idx )| lines[ idx ].end ) .unwrap_or( value.len() ) } + +/// The cursor a page up or down from `cursor`, a page being the lines +/// on screen but one, at the same height on screen as before, and the +/// view moved by that page to show it there. From the first page up, +/// or the last down, the cursor goes to the start or the end. +pub( crate ) fn cursor_visual_page( + canvas: &Canvas, + rect: Rect, + value: &str, + cursor: usize, + font: &TextFont, + scroll: TextScroll, + up: bool, +) -> ( usize, TextScroll ) +{ + let window = Window::shown( canvas, rect, value, font, cursor, scroll ); + let lines = &window.lines; + let page = window.visible.saturating_sub( 1 ).max( 1 ); + let cursor = cursor.min( value.len() ); + let line = window.cursor_line; + let target = if up { line.saturating_sub( page ) } else { ( line + page ).min( lines.len().saturating_sub( 1 ) ) }; + let moved = if target == line + { + if up { 0 } else { value.len() } + } else { + let font_size = window.metrics.font_size; + let x = line_x( canvas, font, value, lines[ line ].start, cursor, font_size ); + let row = lines[ target ]; + byte_offset_in_line( canvas, font, value, row.start, row.end, x, false, true, font_size ) + }; + let first = if up { window.first_line.saturating_sub( page ) } else { ( window.first_line + page ).min( window.max_first ) }; + ( moved, TextScroll { first_line: first, cursor_pos: moved, remainder: 0.0 } ) +} diff --git a/src/widget/text_edit/draw.rs b/src/widget/text_edit/draw.rs index 014abd2..98866d9 100644 --- a/src/widget/text_edit/draw.rs +++ b/src/widget/text_edit/draw.rs @@ -2,12 +2,15 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; -use crate::types::Rect; +use crate::types::{ Color, Rect }; use super::TextEdit; use super::hit_test::{ single_line_align_offset, single_line_scroll_x }; use super::metrics::{ single_line_pad_h, single_line_scale }; use super::scroll::{ TextScroll, Window }; +use super::gutter; +use super::tabs::{ line_x, next_stop, tab_width }; +use super::TextFont; use super::theme; /// Axis-aligned intersection of two rects. Returns `None` when the rects @@ -79,7 +82,8 @@ impl TextEdit /// from its persistent cursor state rather than from the widget itself. /// `selection_anchor` is the other end of the selection range; when /// `selection_anchor == cursor_pos` no highlight is painted. - /// `scroll` is where a multiline field was last scrolled by hand. + /// `scroll` is where a multiline field was last shown, which it + /// returns as it shows it now, for the next frame to keep. pub fn draw( &self, canvas: &mut Canvas, @@ -88,12 +92,11 @@ impl TextEdit cursor_pos: usize, selection_anchor: usize, scroll: TextScroll, - ) + ) -> Option { if self.is_multiline() { - self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor, scroll ); - return; + return Some( self.draw_multiline( canvas, rect, focused, cursor_pos, selection_anchor, scroll ) ); } // Borderless fields skip the surface fill + border stroke // entirely. They still get the inner clip + scroll + alignment @@ -261,6 +264,41 @@ impl TextEdit canvas.draw_image_data( &tinted, iw, ih, dest, 1.0 ); } } + None + } + + /// The bytes `start..end` of the value from `x` on the baseline `y`, each highlighted run in its colour and the rest in the field's. + #[ allow( clippy::too_many_arguments ) ] + fn draw_runs( &self, canvas: &mut Canvas, font: &TextFont, start: usize, end: usize, x: f32, y: f32, font_size: f32 ) + { + let plain = self.color.unwrap_or_else( theme::text ); + let runs = self.highlights.as_deref().map( Vec::as_slice ).unwrap_or( &[] ); + let origin = x; + let tab_w = tab_width( canvas, font, font_size ); + let mut x = x; + let mut at = start; + let draw = |canvas: &mut Canvas, from: usize, to: usize, color: Color, x: &mut f32| + { + for ( i, piece ) in self.value[ from..to ].split( '\t' ).enumerate() + { + if i > 0 { *x = origin + next_stop( *x - origin, tab_w ); } + if piece.is_empty() { continue; } + font.draw( canvas, piece, *x, y, font_size, color ); + *x += font.measure( canvas, piece, font_size ); + } + }; + let first = runs.partition_point( |run| run.range.end <= start ); + for run in &runs[ first.. ] + { + if run.range.start >= end { break; } + let from = run.range.start.max( at ); + let to = run.range.end.min( end ); + if to <= from || !self.value.is_char_boundary( from ) || !self.value.is_char_boundary( to ) { continue; } + if from > at { draw( canvas, at, from, plain, &mut x ); } + draw( canvas, from, to, run.color, &mut x ); + at = to; + } + if at < end { draw( canvas, at, end, plain, &mut x ); } } fn draw_multiline( @@ -271,14 +309,20 @@ impl TextEdit cursor_pos: usize, selection_anchor: usize, scroll: TextScroll, - ) + ) -> TextScroll { - let border_c = if focused { theme::focus_border() } else { theme::border() }; - let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; - canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI ); - canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); + if !self.borderless + { + let border_c = if focused { theme::focus_border() } else { theme::border() }; + let border_w = if focused { theme::FOCUS_BORDER_W } else { theme::BORDER_W }; + canvas.fill_rect( rect, theme::bg(), theme::RADIUS_MULTI ); + canvas.stroke_rect( rect, border_c, border_w, theme::RADIUS_MULTI ); + } - let window = Window::resolve( canvas, rect, &self.value, self.font_size, cursor_pos, scroll ); + let field = rect; + let font = self.text_font(); + let rect = gutter::text_rect( canvas, field, &self.value, &font, self.line_numbers ); + let window = Window::resolve( canvas, rect, &self.value, &font, cursor_pos, scroll ); let m = window.metrics; let line_h = m.line_h; let visual_lines = &window.lines; @@ -307,10 +351,11 @@ impl TextEdit // the glyphs sit on top of the tinted band(s). Iterates // visual lines, so a soft-wrapped logical line gets one // highlight rect per visual row automatically. - if focused && selection_anchor != cursor_pos + if selection_anchor != cursor_pos { - let s = cursor_pos.min( selection_anchor ).min( self.value.len() ); - let e = cursor_pos.max( selection_anchor ).min( self.value.len() ); + let s = cursor_pos.min( selection_anchor ).min( self.value.len() ); + let e = cursor_pos.max( selection_anchor ).min( self.value.len() ); + let color = if focused { theme::selection() } else { theme::selection_inactive() }; for ( i, vl ) in visual_lines.iter().enumerate().skip( first_line ) { let visual_idx = i - first_line; @@ -321,16 +366,16 @@ impl TextEdit let span_start = s.max( vl.start ); let span_end = e.min( vl.end ); if span_start >= span_end { continue; } - let prefix_text = &self.value[ vl.start..span_start ]; - let span_text = &self.value[ span_start..span_end ]; - let x0 = rect.x + m.pad_h + canvas.measure_text( prefix_text, m.font_size ); - let w = canvas.measure_text( span_text, m.font_size ).max( m.caret_w * 2.0 ); + let from = line_x( canvas, &font, &self.value, vl.start, span_start, m.font_size ); + let to = line_x( canvas, &font, &self.value, vl.start, span_end, m.font_size ); + let x0 = rect.x + m.pad_h + from; + let w = ( to - from ).max( m.caret_w * 2.0 ); let sel_rect = Rect { x: x0, y, width: w, height: line_h, }; - canvas.fill_rect( sel_rect, theme::selection(), 2.0 ); + canvas.fill_rect( sel_rect, color, 2.0 ); } } @@ -352,23 +397,15 @@ impl TextEdit { break; } - let line = &self.value[ vl.start..vl.end ]; - canvas.draw_text( - line, - rect.x + m.pad_h, - y, - m.font_size, - self.color.unwrap_or_else( theme::text ), - ); + self.draw_runs( canvas, &font, vl.start, vl.end, rect.x + m.pad_h, y, m.font_size ); } } if focused { let vl = visual_lines[ cursor_visual_idx ]; - let line_prefix = &self.value[ vl.start..safe_cursor ]; let cursor_x = rect.x + m.pad_h - + canvas.measure_text( line_prefix, m.font_size ); + + line_x( canvas, &font, &self.value, vl.start, safe_cursor, m.font_size ); let visual_y_idx = cursor_visual_idx.saturating_sub( first_line ); let cursor_y = rect.y + m.pad_v + visual_y_idx as f32 * line_h; let cursor_rect = Rect @@ -384,11 +421,56 @@ impl TextEdit // Restore whatever clip was active on entry — a single empty // `Vec` from `clip_bounds` means "no clip", which we model by // calling `clear_clip` instead of pushing an empty slice. + if self.line_numbers + { + let band = Rect { x: field.x, width: rect.x - field.x, ..inner_rect }; + canvas.set_clip_rects( &interior_clip( &outer_clip, band ) ); + self.draw_line_numbers( canvas, rect.x, baseline_0, &window, focused.then_some( safe_cursor ) ); + } + if outer_clip.is_empty() { canvas.clear_clip(); } else { canvas.set_clip_rects( &outer_clip ); } + + if let Some( bar ) = window.scrollbar( canvas, rect ) + { + let radius = bar.track.width / 2.0; + canvas.fill_rect( bar.track, theme::scrollbar_track(), radius ); + canvas.fill_rect( bar.thumb, theme::scrollbar_thumb(), radius ); + } + TextScroll { first_line, cursor_pos: safe_cursor, remainder: scroll.remainder } + } + + /// The number of each line on its first row on screen, right-aligned to `right`, the cursor's in the text colour. + fn draw_line_numbers( &self, canvas: &mut Canvas, right: f32, baseline_0: f32, window: &Window, cursor: Option ) + { + let m = window.metrics; + let bytes = self.value.as_bytes(); + let lines = &window.lines; + let Some( first ) = lines.get( window.first_line ) else { return }; + let newlines = |end: usize| bytes[ ..end ].iter().filter( |&&b| b == b'\n' ).count(); + let current = cursor.map( |at| newlines( at ) + 1 ); + let mut number = newlines( first.start ) + 1; + let mono = crate::system_fonts::monospace(); + for ( k, vl ) in lines.iter().enumerate().skip( window.first_line ) + { + let row = k - window.first_line; + if row > window.visible { break; } + let y = baseline_0 + row as f32 * m.line_h; + let starts = vl.start == 0 || bytes[ vl.start - 1 ] == b'\n'; + if !starts { continue; } + if k != window.first_line { number += 1; } + let label = number.to_string(); + let color = if current == Some( number ) { self.color.unwrap_or_else( theme::text ) } else { theme::placeholder() }; + let x = right - gutter::measure( canvas, &label, m.font_size ); + match &mono + { + Some( mono ) => canvas.draw_text_with_font( &label, x, y, m.font_size, color, mono ), + None => canvas.draw_text( &label, x, y, m.font_size, color ), + } + } } } diff --git a/src/widget/text_edit/gutter.rs b/src/widget/text_edit/gutter.rs new file mode 100644 index 0000000..dfef3e3 --- /dev/null +++ b/src/widget/text_edit/gutter.rs @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! The column of line numbers at the left of a multiline field. + +use crate::render::Canvas; +use crate::types::Rect; + +use super::metrics::MultilineMetrics; +use super::theme; +use super::TextFont; + +pub( crate ) fn line_count( value: &str ) -> usize +{ + value.bytes().filter( |&b| b == b'\n' ).count() + 1 +} + +/// The width of `label` in the face the numbers are drawn in: the monospace one, else the field's. +pub( crate ) fn measure( canvas: &Canvas, label: &str, font_size: f32 ) -> f32 +{ + match crate::system_fonts::monospace() + { + Some( mono ) => canvas.measure_text_with_font( label, font_size, &mono ), + None => canvas.measure_text( label, font_size ), + } +} + +/// Wide enough for the number of the last line, with the gap before it. +pub( crate ) fn width( canvas: &Canvas, value: &str, font: &TextFont ) -> f32 +{ + let font_size = MultilineMetrics::resolve( canvas, font.size ).font_size; + let digits = "0".repeat( line_count( value ).to_string().len() ); + measure( canvas, &digits, font_size ) + canvas.geom_px( theme::GUTTER_PAD ) +} + +/// Where the text of a field `rect` goes: right of the line numbers when it shows them. +pub( crate ) fn text_rect( canvas: &Canvas, rect: Rect, value: &str, font: &TextFont, line_numbers: bool ) -> Rect +{ + if !line_numbers { return rect; } + let width = width( canvas, value, font ).min( rect.width ); + Rect { x: rect.x + width, width: rect.width - width, ..rect } +} diff --git a/src/widget/text_edit/hit_test.rs b/src/widget/text_edit/hit_test.rs index 08eeb65..d9a213c 100644 --- a/src/widget/text_edit/hit_test.rs +++ b/src/widget/text_edit/hit_test.rs @@ -2,9 +2,11 @@ // Copyright (C) 2026 Liberux Labs, S. L. use crate::render::Canvas; -use crate::types::{ Length, Point, Rect }; +use crate::types::{ Point, Rect }; use super::metrics::single_line_pad_h; +use super::tabs::{ next_stop, tab_width }; +use super::TextFont; use super::scroll::{ TextScroll, Window }; /// Convert a pointer position inside the widget rect to the byte offset @@ -27,7 +29,7 @@ pub( crate ) fn byte_offset_at( cursor_pos: usize, scroll: TextScroll, align: crate::widget::text::TextAlign, - font_size: Option, + font: &TextFont, pad_h: Option, ) -> usize { @@ -35,7 +37,7 @@ pub( crate ) fn byte_offset_at( if multiline && !secure { - let window = Window::resolve( canvas, rect, value, font_size, cursor_pos, scroll ); + let window = Window::shown( canvas, rect, value, font, cursor_pos, scroll ); let m = window.metrics; let total_lines = window.lines.len(); let visual_idx = ( ( pos.y - rect.y - m.pad_v ) / m.line_h ) @@ -43,11 +45,11 @@ pub( crate ) fn byte_offset_at( let target_idx = ( window.first_line + visual_idx ).min( total_lines.saturating_sub( 1 ) ); let vl = window.lines[ target_idx ]; - byte_offset_in_line( canvas, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, m.font_size ) + byte_offset_in_line( canvas, font, value, vl.start, vl.end, pos.x - rect.x - m.pad_h, false, true, m.font_size ) } else { // Resolve the font-size sentinel (0.0 / negative → widget-scaling mode) // so a click maps against the same size the renderer drew with. - let font_size = super::resolve_font_size( canvas, font_size ); + let font_size = super::resolve_font_size( canvas, font.size ); // Mirror the horizontal scroll *and* alignment the renderer // applies so a click lands on the glyph the user actually // sees, not on the byte offset that would correspond to an @@ -55,9 +57,10 @@ pub( crate ) fn byte_offset_at( let scroll_x = single_line_scroll_x( canvas, rect, value, cursor_pos, secure, font_size, pad_h ); let align_x = single_line_align_offset( canvas, rect, value, secure, align, font_size, pad_h ); byte_offset_in_line( - canvas, value, 0, value.len(), + canvas, &TextFont::default(), value, 0, value.len(), pos.x - rect.x - single_line_pad_h( rect, pad_h ) - align_x + scroll_x, secure, + false, font_size, ) } @@ -163,25 +166,36 @@ pub( crate ) fn single_line_align_offset( /// rule is the standard `text input is text input` behaviour: if the /// click is past the half-glyph mark, the cursor lands *after* the /// glyph. `secure` toggles bullet substitution at measurement time so -/// the same logic works for password fields. +/// the same logic works for password fields; `tabs` has a tab reach +/// the next stop, as a multiline field draws it. +#[ allow( clippy::too_many_arguments ) ] pub( super ) fn byte_offset_in_line( canvas: &Canvas, + font: &TextFont, value: &str, line_start: usize, line_end: usize, target_x: f32, secure: bool, + tabs: bool, font_size: f32, ) -> usize { let line = &value[line_start..line_end]; if target_x <= 0.0 { return line_start; } + let tab_w = if tabs { tab_width( canvas, font, font_size ) } else { 0.0 }; let mut acc_w = 0.0f32; let mut last_byte = line_start; for ( ofs, ch ) in line.char_indices() { - let glyph_str = if secure { "\u{2022}".to_string() } else { ch.to_string() }; - let w = canvas.measure_text( &glyph_str, font_size ); + let w = if secure + { + font.measure_char( canvas, '\u{2022}', font_size ) + } else if tabs && ch == '\t' { + next_stop( acc_w, tab_w ) - acc_w + } else { + font.measure_char( canvas, ch, font_size ) + }; if target_x < acc_w + w * 0.5 { return line_start + ofs; diff --git a/src/widget/text_edit/mod.rs b/src/widget/text_edit/mod.rs index 6b2cdd4..95dd1d1 100644 --- a/src/widget/text_edit/mod.rs +++ b/src/widget/text_edit/mod.rs @@ -20,6 +20,8 @@ pub( crate ) mod hit_test; pub( crate ) mod cursor; pub( crate ) mod metrics; pub( crate ) mod scroll; +pub( crate ) mod tabs; +pub( crate ) mod gutter; mod draw; #[ cfg( test ) ] mod tests; @@ -27,13 +29,76 @@ mod tests; pub use draw::password_toggle_hit_zone; pub( crate ) use hit_test::byte_offset_at; pub( crate ) use scroll::TextScroll; -pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_up }; +pub( crate ) use cursor::{ cursor_visual_down, cursor_visual_end, cursor_visual_home, cursor_visual_page, cursor_visual_up }; /// Resolve the font size into a concrete logical px. `Some( len )` resolves /// against [`Canvas::viewport_logical`] — the font space, so `× dpi_scale` at /// raster yields the right physical size — exactly like the button label; /// `None` follows the widget-scaling mode at the theme default. Shared by the /// draw and hit-test paths, which all carry a canvas. +/// A run of a multiline field's value drawn in a colour of its own, as a +/// syntax highlighter marks keywords, strings and comments. `range` is in +/// bytes of the value and should fall on character boundaries; runs are +/// expected in order and not overlapping, and what no run covers takes the +/// field's colour. +#[ derive( Clone, Debug, PartialEq ) ] +pub struct Highlight +{ + pub range: std::ops::Range, + pub color: Color, +} + +/// The size of a field's text and, for a multiline field, its face, +/// which is the canvas's unless [`TextEdit::font`] gave another; every +/// path that lays the text out measures and draws it through this, so +/// they all agree. +#[ derive( Clone, Default ) ] +pub( crate ) struct TextFont +{ + pub size: Option, + pub face: Option>, +} + +impl TextFont +{ + pub( crate ) fn measure( &self, canvas: &Canvas, text: &str, px: f32 ) -> f32 + { + match &self.face + { + Some( face ) => canvas.measure_text_with_font( text, px, face ), + None => canvas.measure_text( text, px ), + } + } + + pub( crate ) fn measure_char( &self, canvas: &Canvas, ch: char, px: f32 ) -> f32 + { + match &self.face + { + Some( face ) => canvas.measure_char_with_font( ch, px, face ), + None => canvas.measure_char( ch, px ), + } + } + + pub( crate ) fn draw( &self, canvas: &mut Canvas, text: &str, x: f32, y: f32, px: f32, color: Color ) + { + match &self.face + { + Some( face ) => canvas.draw_text_with_font( text, x, y, px, color, face ), + None => canvas.draw_text( text, x, y, px, color ), + } + } + + /// Which face, for the caches keyed by it. + pub( crate ) fn face_key( &self, canvas: &Canvas ) -> usize + { + match &self.face + { + Some( face ) => Arc::as_ptr( face ) as usize, + None => canvas.font() as *const crate::font_face::FontFace as usize, + } + } +} + pub( crate ) fn resolve_font_size( canvas: &Canvas, fs: Option ) -> f32 { fs @@ -105,6 +170,8 @@ pub struct TextEdit /// `Arc` (not `Box`) so the layout pass can clone it into the per-leaf /// handler snapshot for O(1) dispatch on input events. pub( crate ) on_change: Option Msg>>, + /// Callback invoked with the cursor's byte offset whenever it moves. + pub( crate ) on_cursor: Option Msg>>, /// Message emitted when the user presses Enter. pub( crate ) on_submit: Option, /// Message emitted when the field takes the keyboard focus. @@ -192,6 +259,14 @@ pub struct TextEdit /// When `true`, the field renders its box and value but takes no keyboard /// focus and accepts no input — a read-only display styled as a text field. pub( crate ) read_only: bool, + /// Runs of a multiline value drawn in colours of their own. + pub( crate ) highlights: Option>>, + /// What the Tab key types while the field has the focus, instead of moving the focus on. + pub( crate ) tab_text: Option, + /// Whether a multiline field numbers its lines in a column at its left. + pub( crate ) line_numbers: bool, + /// The face of a multiline field's text; `None` is the canvas's. + pub( crate ) font: Option>, } impl TextEdit @@ -207,6 +282,7 @@ impl TextEdit placeholder, value, on_change: None, + on_cursor: None, on_submit: None, on_focus: None, color: None, @@ -227,6 +303,10 @@ impl TextEdit pin_cursor_to_end: false, password_toggle: None, read_only: false, + highlights: None, + tab_text: None, + line_numbers: false, + font: None, } } @@ -374,6 +454,49 @@ impl TextEdit self } + /// Draw runs of a multiline value in colours of their own (see + /// [`Highlight`]), as an editor highlights code. Shared through an + /// `Arc`, since a view is rebuilt on every frame and a long text has + /// many runs. Single-line fields draw in one colour. + pub fn highlights( mut self, runs: Arc> ) -> Self + { + self.highlights = Some( runs ); + self + } + + /// Have the Tab key type `text`, such as four spaces, while the field + /// has the focus, as an editor does, instead of moving the focus to + /// the next widget. + pub fn tab_text( mut self, text: impl Into ) -> Self + { + self.tab_text = Some( text.into() ); + self + } + + /// Number the lines of a multiline field in a column at its left, as + /// wide as the number of the last line needs; a line that wraps is + /// numbered on its first row. + pub fn line_numbers( mut self, on: bool ) -> Self + { + self.line_numbers = on; + self + } + + /// Draw a multiline field's text in `face`, such as [`crate::monospace_font`] + /// for an editor of code, instead of the theme's; its size is still + /// [`Self::font_size`]'s. + pub fn font( mut self, face: Arc ) -> Self + { + self.font = Some( face ); + self + } + + /// The size and face its text is laid out in. + pub( crate ) fn text_font( &self ) -> TextFont + { + TextFont { size: self.font_size, face: self.font.clone().filter( |_| self.is_multiline() ) } + } + /// Set the callback invoked on every keystroke with the updated value. pub fn on_change( mut self, f: impl Fn(String) -> Msg + 'static ) -> Self { @@ -381,6 +504,15 @@ impl TextEdit self } + /// Set the callback invoked with the cursor's byte offset in the value + /// once it has been placed and whenever it moves or the value changes + /// length, as an editor's status bar showing the line needs. + pub fn on_cursor( mut self, f: impl Fn( usize ) -> Msg + 'static ) -> Self + { + self.on_cursor = Some( Arc::new( f ) ); + self + } + /// Set the message emitted when Enter is pressed. pub fn on_submit( mut self, msg: Msg ) -> Self { @@ -534,8 +666,10 @@ impl TextEdit scroll: TextScroll, ) -> usize { + let font = self.text_font(); + let rect = gutter::text_rect( canvas, rect, &self.value, &font, self.line_numbers && self.is_multiline() ); byte_offset_at( - canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, self.font_size, self.pad_h, + canvas, rect, pos, &self.value, self.is_multiline(), self.effective_secure(), cursor_pos, scroll, self.align, &font, self.pad_h, ) } @@ -600,11 +734,17 @@ impl TextEdit let mapper = Arc::clone( f ); Arc::new( move |s| ( *mapper )( ( *old )( s ) ) ) } ); + let on_cursor = self.on_cursor.clone().map( |old| -> Arc U> + { + let mapper = Arc::clone( f ); + Arc::new( move |at| ( *mapper )( ( *old )( at ) ) ) + } ); TextEdit { placeholder: self.placeholder.clone(), value: self.value.clone(), on_change, + on_cursor, on_submit: self.on_submit.clone().map( |m| ( *f )( m ) ), on_focus: self.on_focus.clone().map( |m| ( *f )( m ) ), color: self.color, @@ -625,6 +765,10 @@ impl TextEdit pin_cursor_to_end: self.pin_cursor_to_end, password_toggle: self.password_toggle.clone().map( |( v, m )| ( v, ( *f )( m ) ) ), read_only: self.read_only, + highlights: self.highlights.clone(), + tab_text: self.tab_text.clone(), + line_numbers: self.line_numbers, + font: self.font.clone(), } } } diff --git a/src/widget/text_edit/scroll.rs b/src/widget/text_edit/scroll.rs index e661aea..b5332b9 100644 --- a/src/widget/text_edit/scroll.rs +++ b/src/widget/text_edit/scroll.rs @@ -4,10 +4,14 @@ //! Vertical scroll of a multiline field: the window of visual lines on //! screen, and how the wheel moves it. +use std::rc::Rc; + use crate::render::Canvas; -use crate::types::{ Length, Rect }; +use crate::types::Rect; use super::metrics::MultilineMetrics; +use super::theme; +use super::TextFont; use super::wrapping::{ compute_visual_lines, VisualLine }; /// Visual lines one wheel notch moves. @@ -77,10 +81,22 @@ impl TextScroll pub( crate ) struct Window { pub metrics: MultilineMetrics, - pub lines: Vec, + pub lines: Rc>, pub cursor_line: usize, pub first_line: usize, pub max_first: usize, + pub visible: usize, +} + +/// The scroll bar of a multiline field whose lines do not all fit: +/// the track along its right edge, the thumb on it, and the band a +/// press takes as one on the bar. +#[ derive( Clone, Copy, Debug ) ] +pub( crate ) struct Scrollbar +{ + pub track: Rect, + pub thumb: Rect, + pub hit: Rect, } impl Window @@ -89,21 +105,72 @@ impl Window canvas: &Canvas, rect: Rect, value: &str, - font_size: Option, + font: &TextFont, cursor_pos: usize, scroll: TextScroll, ) -> Self { - let metrics = MultilineMetrics::resolve( canvas, font_size ); - let lines = compute_visual_lines( canvas, value, metrics.inner_width( rect ), metrics.font_size ); + let metrics = MultilineMetrics::resolve( canvas, font.size ); + let lines = compute_visual_lines( canvas, font, value, metrics.inner_width( rect ), metrics.font_size ); let cursor_pos = cursor_pos.min( value.len() ); + let scroll = TextScroll { cursor_pos: scroll.cursor_pos.min( value.len() ), ..scroll }; let cursor_line = lines.iter() .position( |vl| cursor_pos >= vl.start && cursor_pos <= vl.end ) .unwrap_or( lines.len().saturating_sub( 1 ) ); let visible = metrics.visible_lines( rect ); let first_line = scroll.first_visible_line( visible, lines.len(), cursor_line, cursor_pos ); let max_first = lines.len().saturating_sub( visible ); - Self { metrics, lines, cursor_line, first_line, max_first } + Self { metrics, lines, cursor_line, first_line, max_first, visible } + } + + /// The lines as the field last showed them, wherever the cursor went + /// since, so that a press, the wheel or a page acts on what is on + /// screen; `scroll` is the view the last frame kept. + pub( crate ) fn shown( + canvas: &Canvas, + rect: Rect, + value: &str, + font: &TextFont, + cursor_pos: usize, + scroll: TextScroll, + ) -> Self + { + let mut window = Self::resolve( canvas, rect, value, font, cursor_pos, scroll ); + window.first_line = scroll.first_line.min( window.max_first ); + window + } + + pub( crate ) fn scrollbar( &self, canvas: &Canvas, rect: Rect ) -> Option + { + if self.max_first == 0 { return None; } + let margin = canvas.geom_px( theme::SCROLLBAR_MARGIN ); + let width = canvas.geom_px( theme::SCROLLBAR_W ); + let track = Rect + { + x: rect.x + rect.width - margin - width, + y: rect.y + margin, + width, + height: ( rect.height - margin * 2.0 ).max( 0.0 ), + }; + let share = self.visible as f32 / self.lines.len() as f32; + let thumb_h = ( track.height * share ).max( canvas.geom_px( theme::SCROLLBAR_MIN ) ).min( track.height ); + let thumb_y = track.y + ( track.height - thumb_h ) * self.first_line as f32 / self.max_first as f32; + let hit_w = canvas.geom_px( theme::SCROLLBAR_HIT ).min( rect.width ); + Some( Scrollbar + { + track, + thumb: Rect { y: thumb_y, height: thumb_h, ..track }, + hit: Rect { x: rect.x + rect.width - hit_w, width: hit_w, ..rect }, + } ) + } + + /// The first line on screen with the top of `bar`'s thumb at `thumb_y`. + pub( crate ) fn first_line_for( &self, bar: &Scrollbar, thumb_y: f32 ) -> usize + { + let travel = bar.track.height - bar.thumb.height; + if travel <= 0.0 { return 0; } + let share = ( ( thumb_y - bar.track.y ) / travel ).clamp( 0.0, 1.0 ); + ( share * self.max_first as f32 ).round() as usize } pub( crate ) fn can_scroll( &self, delta_px: f32 ) -> bool diff --git a/src/widget/text_edit/tabs.rs b/src/widget/text_edit/tabs.rs new file mode 100644 index 0000000..891e125 --- /dev/null +++ b/src/widget/text_edit/tabs.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: LGPL-2.1-only +// Copyright (C) 2026 Liberux Labs, S. L. + +//! Horizontal positions in a row of a multiline field, where a tab +//! reaches the next stop. + +use crate::render::Canvas; + +use super::theme; +use super::TextFont; + +pub( crate ) fn tab_width( canvas: &Canvas, font: &TextFont, px: f32 ) -> f32 +{ + font.measure_char( canvas, '0', px ) * theme::TAB_COLUMNS +} + +/// The first tab stop past `x`, measured from the start of the row. +pub( crate ) fn next_stop( x: f32, tab_w: f32 ) -> f32 +{ + if tab_w <= 0.0 { return x; } + ( ( x / tab_w ).floor() + 1.0 ) * tab_w +} + +/// How far into the row starting at byte `start` the byte `at` is. +pub( crate ) fn line_x( canvas: &Canvas, font: &TextFont, value: &str, start: usize, at: usize, px: f32 ) -> f32 +{ + let text = &value[ start..at ]; + if !text.contains( '\t' ) { return font.measure( canvas, text, px ); } + let tab_w = tab_width( canvas, font, px ); + let mut x = 0.0; + for ( i, piece ) in text.split( '\t' ).enumerate() + { + if i > 0 { x = next_stop( x, tab_w ); } + if !piece.is_empty() { x += font.measure( canvas, piece, px ); } + } + x +} diff --git a/src/widget/text_edit/tests.rs b/src/widget/text_edit/tests.rs index 3fe3116..4b79058 100644 --- a/src/widget/text_edit/tests.rs +++ b/src/widget/text_edit/tests.rs @@ -760,3 +760,135 @@ fn interior_clip_outside_the_parent_clip_clips_everything() assert_eq!( clip.len(), 1 ); assert_eq!( clip[ 0 ].width * clip[ 0 ].height, 0.0 ); } + +// ── Tabs, wrapping and the scroll bar ───────────────────────────────────── + +fn canvas() -> crate::render::Canvas +{ + crate::render::Canvas::new( 800, 600 ) +} + +#[ test ] +fn a_tab_reaches_the_next_stop() +{ + let canvas = canvas(); + let tab_w = super::tabs::tab_width( &canvas, &super::TextFont::default(), 16.0 ); + assert!( tab_w > canvas.measure_text( "a", 16.0 ) ); + assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "\tx", 0, 1, 16.0 ), tab_w ); + assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "a\tx", 0, 2, 16.0 ), tab_w ); + assert_eq!( super::tabs::line_x( &canvas, &super::TextFont::default(), "\t\tx", 0, 2, 16.0 ), tab_w * 2.0 ); +} + +#[ test ] +fn a_click_on_a_tab_lands_on_the_nearer_side() +{ + let canvas = canvas(); + let tab_w = super::tabs::tab_width( &canvas, &super::TextFont::default(), 16.0 ); + let at = |x: f32| super::hit_test::byte_offset_in_line( &canvas, &super::TextFont::default(), "\tx", 0, 2, x, false, true, 16.0 ); + assert_eq!( at( tab_w * 0.25 ), 0 ); + assert_eq!( at( tab_w * 0.75 ), 1 ); +} + +#[ test ] +fn the_same_value_is_wrapped_once() +{ + let canvas = canvas(); + let value = "one two three\nfour"; + let first = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), value, 300.0, 16.0 ); + let again = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), value, 300.0, 16.0 ); + assert!( std::rc::Rc::ptr_eq( &first, &again ) ); + let other = super::wrapping::compute_visual_lines( &canvas, &super::TextFont::default(), "one two three\nfive", 300.0, 16.0 ); + assert!( !std::rc::Rc::ptr_eq( &first, &other ) ); +} + +#[ test ] +fn the_scroll_bar_shows_only_when_lines_are_hidden_and_spans_them() +{ + use super::scroll::{ TextScroll, Window }; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let short = Window::resolve( &canvas, rect, "a\nb", &super::TextFont::default(), 0, TextScroll::default() ); + assert!( short.scrollbar( &canvas, rect ).is_none() ); + + let long = "line\n".repeat( 100 ); + let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 0, TextScroll::default() ); + let bar = window.scrollbar( &canvas, rect ).expect( "a scroll bar" ); + assert_eq!( bar.thumb.y, bar.track.y ); + assert!( bar.thumb.height < bar.track.height ); + assert!( bar.hit.contains( crate::types::Point { x: bar.thumb.x, y: bar.thumb.y } ) ); + assert_eq!( window.first_line_for( &bar, bar.track.y ), 0 ); + assert_eq!( window.first_line_for( &bar, bar.track.y + bar.track.height ), window.max_first ); +} + +#[ test ] +fn a_scroll_made_with_the_cursor_at_its_end_sentinel_holds() +{ + use super::scroll::{ TextScroll, Window }; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let long = "line\n".repeat( 100 ); + let scroll = TextScroll { first_line: 10, cursor_pos: usize::MAX, remainder: 0.0 }; + let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, scroll ); + assert_eq!( window.first_line, 10 ); +} + +#[ test ] +fn a_view_shown_stays_while_the_cursor_moves_inside_it() +{ + use super::scroll::{ TextScroll, Window }; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let long = "line\n".repeat( 100 ); + let shown = TextScroll { first_line: 20, cursor_pos: 125, remainder: 0.0 }; + let window = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 120, shown ); + assert_eq!( window.first_line, 20 ); +} + +#[ test ] +fn a_page_keeps_the_cursor_at_its_height_and_moves_the_view() +{ + use super::cursor::cursor_visual_page; + use super::scroll::{ TextScroll, Window }; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let long = "line\n".repeat( 100 ); + let page = Window::resolve( &canvas, rect, &long, &super::TextFont::default(), 0, TextScroll::default() ).visible - 1; + + let ( down, scroll ) = cursor_visual_page( &canvas, rect, &long, 0, &super::TextFont::default(), TextScroll::default(), false ); + assert_eq!( ( down, scroll.first_line ), ( page * 5, page ) ); + let ( up, scroll ) = cursor_visual_page( &canvas, rect, &long, down, &super::TextFont::default(), scroll, true ); + assert_eq!( ( up, scroll.first_line ), ( 0, 0 ) ); + let ( start, _ ) = cursor_visual_page( &canvas, rect, &long, 0, &super::TextFont::default(), TextScroll::default(), true ); + assert_eq!( start, 0 ); + let ( end, _ ) = cursor_visual_page( &canvas, rect, &long, long.len(), &super::TextFont::default(), TextScroll::default(), false ); + assert_eq!( end, long.len() ); +} + +#[ test ] +fn a_click_lands_on_the_view_shown_whatever_the_cursor_did_since() +{ + use super::scroll::{ TextScroll, Window }; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + let long = "line\n".repeat( 100 ); + let shown = TextScroll { first_line: 20, cursor_pos: 0, remainder: 0.0 }; + assert_eq!( Window::shown( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, shown ).first_line, 20 ); + + let m = Window::shown( &canvas, rect, &long, &super::TextFont::default(), usize::MAX, shown ).metrics; + let click = crate::types::Point { x: rect.x + m.pad_h + 1.0, y: rect.y + m.pad_v + m.line_h * 2.5 }; + let at = super::byte_offset_at( &canvas, rect, click, &long, true, false, usize::MAX, shown, crate::widget::text::TextAlign::Left, &super::TextFont::default(), None ); + assert_eq!( at, 22 * 5 ); +} + +#[ test ] +fn the_line_numbers_take_the_width_of_the_last_number() +{ + use super::gutter::text_rect; + let canvas = canvas(); + let rect = crate::types::Rect { x: 0.0, y: 0.0, width: 400.0, height: 200.0 }; + assert_eq!( text_rect( &canvas, rect, "a", &super::TextFont::default(), false ).x, 0.0 ); + let nine = text_rect( &canvas, rect, &"a\n".repeat( 8 ), &super::TextFont::default(), true ); + let ten = text_rect( &canvas, rect, &"a\n".repeat( 9 ), &super::TextFont::default(), true ); + assert!( nine.x > 0.0 && ten.x > nine.x ); + assert!( ( ten.x + ten.width - rect.width ).abs() < 1e-3 ); +} diff --git a/src/widget/text_edit/theme.rs b/src/widget/text_edit/theme.rs index 80a77b3..bc7e294 100644 --- a/src/widget/text_edit/theme.rs +++ b/src/widget/text_edit/theme.rs @@ -17,6 +17,13 @@ pub fn selection() -> Color Color::rgba( a.r, a.g, a.b, 0.35 ) } +/// The selection of a multiline field without the focus. +pub fn selection_inactive() -> Color +{ + let c = crate::theme::palette().text_secondary; + Color::rgba( c.r, c.g, c.b, 0.25 ) +} + pub const BORDER_W: f32 = 1.5; pub const FOCUS_BORDER_W: f32 = 2.0; pub const HEIGHT: f32 = 48.0; @@ -44,3 +51,20 @@ pub const PASSWORD_TOGGLE_SIZE: f32 = 20.0; /// Extra horizontal slop on each side of the eye icon's hit zone /// to make the toggle finger-friendly even on touch panels. pub const PASSWORD_TOGGLE_SLOP: f32 = 4.0; +/// Columns from one tab stop of a multiline field to the next, each as wide as a digit, as a monospace column is. +pub const TAB_COLUMNS: f32 = 4.0; +/// Width of a multiline field's scroll bar, and its gap to the field's edge. +pub const SCROLLBAR_W: f32 = 6.0; +pub const SCROLLBAR_MARGIN: f32 = 3.0; +/// Width from the field's right edge that a press takes as one on the scroll bar. +pub const SCROLLBAR_HIT: f32 = 20.0; +pub const SCROLLBAR_MIN: f32 = 32.0; + +pub fn scrollbar_track() -> Color +{ + let c = crate::theme::palette().divider; + Color::rgba( c.r, c.g, c.b, c.a * 0.5 ) +} +pub fn scrollbar_thumb() -> Color { crate::theme::palette().text_secondary } +/// Before the line numbers of a multiline field that shows them. +pub const GUTTER_PAD: f32 = 12.0; diff --git a/src/widget/text_edit/wrapping.rs b/src/widget/text_edit/wrapping.rs index 86c42f6..4eb36d8 100644 --- a/src/widget/text_edit/wrapping.rs +++ b/src/widget/text_edit/wrapping.rs @@ -1,8 +1,14 @@ // SPDX-License-Identifier: LGPL-2.1-only // Copyright (C) 2026 Liberux Labs, S. L. +use std::hash::{ Hash, Hasher }; +use std::rc::Rc; + use crate::render::Canvas; +use super::tabs::{ next_stop, tab_width }; +use super::TextFont; + /// One visual row of a wrapped multiline TextEdit. `start..end` is a /// byte range inside the value; the renderer draws those bytes on a /// single visual row, even if the underlying logical line (the run @@ -17,22 +23,64 @@ pub( crate ) struct VisualLine pub end: usize, } +/// What a wrap layout depends on: the value's length and hash, the row width, and the font and its size. +#[ derive( Clone, Copy, PartialEq ) ] +struct LayoutKey +{ + hash: u64, + len: usize, + width: u32, + px: u32, + font: usize, +} + +const LAYOUT_CACHE_CAP: usize = 4; + +thread_local! +{ + static LAYOUTS: std::cell::RefCell> )>> = const { std::cell::RefCell::new( Vec::new() ) }; +} + /// Wrap `value` into a list of visual lines that each fit within /// `inner_width` when rendered at `font_size`. Hard `\n` breaks are /// always row boundaries; long logical lines are additionally split /// at the last whitespace before the row would overflow, falling /// back to a per-character break inside a single oversized word. -/// -/// O(N) per call where N is `value.len()` — `draw_multiline` runs -/// it once per frame, which is fine for the kilobyte-scale buffers a -/// `text_edit` is meant to hold (anything larger should use a -/// dedicated text-area widget with cached layout). +/// The last layouts are kept, so drawing, hit testing and caret +/// motion over the same value wrap it once. pub( crate ) fn compute_visual_lines( canvas: &Canvas, + font: &TextFont, value: &str, inner_width: f32, font_size: f32, -) -> Vec +) -> Rc> +{ + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + value.hash( &mut hasher ); + let key = LayoutKey + { + hash: hasher.finish(), + len: value.len(), + width: inner_width.to_bits(), + px: ( font_size * canvas.dpi_scale() ).to_bits(), + font: font.face_key( canvas ), + }; + if let Some( lines ) = LAYOUTS.with( |c| c.borrow().iter().find( |( k, _ )| *k == key ).map( |( _, l )| Rc::clone( l ) ) ) + { + return lines; + } + let lines = Rc::new( wrap( canvas, font, value, inner_width, font_size ) ); + LAYOUTS.with( |c| + { + let mut c = c.borrow_mut(); + if c.len() >= LAYOUT_CACHE_CAP { c.remove( 0 ); } + c.push( ( key, Rc::clone( &lines ) ) ); + } ); + lines +} + +fn wrap( canvas: &Canvas, font: &TextFont, value: &str, inner_width: f32, font_size: f32 ) -> Vec { let mut out = Vec::new(); if value.is_empty() @@ -40,7 +88,8 @@ pub( crate ) fn compute_visual_lines( out.push( VisualLine { start: 0, end: 0 } ); return out; } - let max_w = inner_width.max( 1.0 ); + let max_w = inner_width.max( 1.0 ); + let tab_w = tab_width( canvas, font, font_size ); let mut byte_pos = 0usize; for logical_line in value.split( '\n' ) { @@ -48,23 +97,25 @@ pub( crate ) fn compute_visual_lines( let line_end = byte_pos + logical_line.len(); if line_start == line_end { - // Empty logical line still occupies a row. out.push( VisualLine { start: line_start, end: line_end } ); } else { - wrap_logical_line( canvas, value, line_start, line_end, max_w, font_size, &mut out ); + wrap_logical_line( canvas, font, value, line_start, line_end, max_w, font_size, tab_w, &mut out ); } - byte_pos = line_end + 1; // step past the '\n' + byte_pos = line_end + 1; } out } +#[ allow( clippy::too_many_arguments ) ] fn wrap_logical_line( canvas: &Canvas, + font: &TextFont, value: &str, start: usize, end: usize, max_width: f32, font_size: f32, + tab_w: f32, out: &mut Vec, ) { @@ -72,12 +123,13 @@ fn wrap_logical_line( while cur < end { let segment = &value[cur..end]; - let break_at = find_wrap_offset( canvas, segment, max_width, font_size ); + let break_at = find_wrap_offset( canvas, font, segment, max_width, font_size, tab_w ); // Always advance at least one byte so a degenerate measure // (zero-width font, ridiculously narrow rect) still // terminates instead of looping forever. let raw_next = cur + break_at; - let next = raw_next.max( cur + 1 ).min( end ); + let mut next = raw_next.max( cur + 1 ).min( end ); + while !value.is_char_boundary( next ) { next += 1; } out.push( VisualLine { start: cur, end: next } ); cur = next; } @@ -90,17 +142,23 @@ fn wrap_logical_line( /// `max_width`. fn find_wrap_offset( canvas: &Canvas, + font: &TextFont, segment: &str, max_width: f32, font_size: f32, + tab_w: f32, ) -> usize { let mut acc_w = 0.0f32; let mut last_break_after: Option = None; for ( i, ch ) in segment.char_indices() { - let glyph = ch.to_string(); - let w = canvas.measure_text( &glyph, font_size ); + let w = if ch == '\t' + { + next_stop( acc_w, tab_w ) - acc_w + } else { + font.measure_char( canvas, ch, font_size ) + }; if acc_w + w > max_width && i > 0 { return last_break_after.unwrap_or( i ); diff --git a/tests/layout_stack_spacer.rs b/tests/layout_stack_spacer.rs index 39d7fd1..f952c6c 100644 --- a/tests/layout_stack_spacer.rs +++ b/tests/layout_stack_spacer.rs @@ -71,6 +71,19 @@ fn stack_start_top_anchors_to_top_left() assert_eq!( child_rect.y, 50.0 ); } +#[ test ] +fn stack_child_larger_than_the_stack_stays_inside_it() +{ + let canvas = make_canvas(); + let child = spacer().width( 500.0 ).height( 500.0 ); + let s = stack::<()>().push_aligned( child, HAlign::End, VAlign::Bottom ); + let layout = s.layout( rect( 10.0, 20.0, 400.0, 300.0 ), &canvas ); + + let ( child_rect, _ ) = layout[ 0 ]; + // Aligned to the bottom right, it would otherwise start 200 px above the stack and 100 px left of it. + assert_eq!( child_rect, rect( 10.0, 20.0, 400.0, 300.0 ) ); +} + // ── Stack: margin ───────────────────────────────────────────────────────────── #[ test ]