Something went wrong. Try again.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497849884998500850185028503850485058506850785088509851085118512851385148515851685178518851985208521852285238524852585268527852885298530853185328533853485358536853785388539854085418542854385448545854685478548854985508551855285538554855585568557855885598560856185628563856485658566856785688569857085718572857385748575857685778578857985808581858285838584858585868587858885898590859185928593859485958596859785988599860086018602860386048605860686078608860986108611861286138614861586168617861886198620862186228623862486258626862786288629863086318632863386348635863686378638863986408641864286438644864586468647864886498650865186528653865486558656865786588659866086618662866386648665866686678668866986708671867286738674867586768677867886798680868186828683868486858686868786888689869086918692869386948695869686978698869987008701870287038704870587068707870887098710871187128713871487158716871787188719872087218722872387248725872687278728872987308731873287338734873587368737873887398740874187428743874487458746874787488749875087518752875387548755875687578758875987608761876287638764876587668767876887698770877187728773877487758776877787788779878087818782878387848785878687878788878987908791879287938794879587968797879887998800880188028803880488058806880788088809881088118812881388148815881688178818881988208821882288238824882588268827882888298830883188328833883488358836883788388839884088418842884388448845884688478848884988508851885288538854885588568857885888598860886188628863886488658866886788688869887088718872887388748875887688778878887988808881888288838884888588868887888888898890889188928893889488958896889788988899890089018902890389048905890689078908890989108911891289138914891589168917891889198920892189228923892489258926892789288929893089318932893389348935893689378938893989408941894289438944894589468947894889498950895189528953895489558956895789588959896089618962896389648965896689678968896989708971897289738974897589768977897889798980898189828983898489858986898789888989899089918992899389948995899689978998899990009001900290039004900590069007900890099010901190129013901490159016901790189019902090219022902390249025902690279028902990309031903290339034903590369037903890399040904190429043904490459046904790489049905090519052905390549055905690579058905990609061906290639064906590669067906890699070907190729073907490759076907790789079908090819082908390849085908690879088908990909091909290939094909590969097909890999100910191029103910491059106910791089109911091119112911391149115911691179118911991209121912291239124912591269127912891299130913191329133913491359136913791389139914091419142914391449145914691479148914991509151915291539154915591569157915891599160916191629163916491659166916791689169917091719172917391749175917691779178917991809181918291839184918591869187918891899190919191929193919491959196919791989199920092019202920392049205920692079208920992109211921292139214921592169217921892199220922192229223922492259226922792289229923092319232923392349235923692379238923992409241924292439244924592469247924892499250925192529253925492559256925792589259926092619262926392649265926692679268926992709271927292739274927592769277927892799280928192829283928492859286928792889289929092919292929392949295929692979298929993009301930293039304930593069307930893099310931193129313931493159316931793189319932093219322932393249325932693279328932993309331933293339334933593369337933893399340934193429343934493459346934793489349935093519352935393549355935693579358935993609361936293639364936593669367936893699370937193729373937493759376937793789379938093819382938393849385938693879388938993909391939293939394939593969397939893999400940194029403940494059406940794089409941094119412941394149415941694179418941994209421942294239424942594269427942894299430943194329433943494359436943794389439944094419442944394449445944694479448944994509451945294539454945594569457945894599460946194629463946494659466946794689469947094719472947394749475947694779478947994809481948294839484948594869487948894899490949194929493949494959496949794989499950095019502950395049505950695079508950995109511951295139514951595169517951895199520952195229523952495259526952795289529953095319532953395349535953695379538953995409541954295439544954595469547954895499550955195529553955495559556955795589559956095619562956395649565956695679568956995709571957295739574957595769577957895799580958195829583958495859586958795889589959095919592959395949595959695979598959996009601960296039604960596069607960896099610961196129613961496159616961796189619962096219622962396249625962696279628962996309631963296339634963596369637963896399640964196429643964496459646964796489649965096519652965396549655965696579658965996609661966296639664966596669667966896699670967196729673967496759676967796789679968096819682968396849685968696879688968996909691969296939694969596969697969896999700970197029703970497059706970797089709971097119712971397149715971697179718971997209721972297239724972597269727972897299730973197329733973497359736973797389739974097419742974397449745974697479748974997509751975297539754975597569757975897599760976197629763976497659766976797689769977097719772977397749775977697779778977997809781978297839784978597869787978897899790979197929793979497959796979797989799980098019802980398049805980698079808980998109811981298139814981598169817981898199820982198229823982498259826982798289829983098319832983398349835983698379838983998409841984298439844984598469847984898499850985198529853985498559856985798589859986098619862986398649865986698679868986998709871987298739874987598769877987898799880988198829883988498859886988798889889989098919892989398949895989698979898989999009901990299039904990599069907990899099910991199129913991499159916991799189919992099219922992399249925992699279928992999309931993299339934993599369937993899399940994199429943994499459946994799489949995099519952995399549955995699579958995999609961996299639964996599669967996899699970997199729973997499759976997799789979998099819982998399849985998699879988998999909991999299939994999599969997999899991000010001100021000310004100051000610007100081000910010100111001210013100141001510016100171001810019100201002110022100231002410025100261002710028100291003010031100321003310034100351003610037100381003910040100411004210043100441004510046100471004810049100501005110052100531005410055100561005710058100591006010061100621006310064100651006610067100681006910070100711007210073100741007510076100771007810079100801008110082100831008410085100861008710088100891009010091100921009310094100951009610097100981009910100101011010210103101041010510106101071010810109101101011110112101131011410115101161011710118101191012010121101221012310124101251012610127101281012910130101311013210133101341013510136101371013810139101401014110142101431014410145101461014710148101491015010151101521015310154101551015610157101581015910160101611016210163101641016510166101671016810169101701017110172101731017410175101761017710178101791018010181101821018310184101851018610187101881018910190101911019210193101941019510196101971019810199102001020110202102031020410205102061020710208102091021010211102121021310214102151021610217102181021910220102211022210223102241022510226102271022810229102301023110232102331023410235102361023710238102391024010241102421024310244102451024610247102481024910250102511025210253102541025510256102571025810259102601026110262102631026410265102661026710268102691027010271102721027310274102751027610277102781027910280102811028210283102841028510286102871028810289102901029110292102931029410295102961029710298102991030010301103021030310304103051030610307103081030910310103111031210313103141031510316103171031810319103201032110322103231032410325103261032710328103291033010331103321033310334103351033610337103381033910340103411034210343103441034510346103471034810349103501035110352103531035410355103561035710358103591036010361103621036310364103651036610367103681036910370103711037210373103741037510376103771037810379103801038110382103831038410385103861038710388103891039010391103921039310394103951039610397103981039910400104011040210403104041040510406104071040810409104101041110412104131041410415104161041710418104191042010421104221042310424104251042610427104281042910430104311043210433104341043510436104371043810439104401044110442104431044410445104461044710448104491045010451104521045310454104551045610457104581045910460104611046210463104641046510466104671046810469104701047110472104731047410475104761047710478104791048010481104821048310484104851048610487104881048910490104911049210493104941049510496104971049810499105001050110502105031050410505105061050710508105091051010511const Screen = @This();
const std = @import("std");const build_options = @import("terminal_options");const Allocator = std.mem.Allocator;const assert = @import("../quirks.zig").inlineAssert;const ansi = @import("ansi.zig");const charsets = @import("charsets.zig");const fastmem = @import("../fastmem.zig");const kitty = @import("kitty.zig");const sgr = @import("sgr.zig");const tripwire = @import("../tripwire.zig");const unicode = @import("../unicode/main.zig");const Selection = @import("Selection.zig");const PageList = @import("PageList.zig");const selection_codepoints = @import("selection_codepoints.zig");const StringMap = @import("StringMap.zig");const ScreenFormatter = @import("formatter.zig").ScreenFormatter;const osc = @import("osc.zig");const pagepkg = @import("page.zig");const point = @import("point.zig");const size = @import("size.zig");const style = @import("style.zig");const hyperlink = @import("hyperlink.zig");const Offset = size.Offset;const Page = pagepkg.Page;const Row = pagepkg.Row;const Cell = pagepkg.Cell;const Pin = PageList.Pin;
pub const CursorStyle = @import("cursor.zig").Style;
const log = std.log.scoped(.screen);
/// The general purpose allocator to use for all memory allocations./// Unfortunately some screen operations do require allocation.alloc: Allocator,
/// The list of pages in the screen.pages: PageList,
/// Special-case where we want no scrollback whatsoever. We have to flag/// this because max_size 0 in PageList gets rounded up to two pages so/// we can always have an active screen.no_scrollback: bool = false,
/// The current cursor positioncursor: Cursor,
/// The saved cursorsaved_cursor: ?SavedCursor = null,
/// The selection for this screen (if any). This MUST be a tracked selection/// otherwise the selection will become invalid. Instead of accessing this/// directly to set it, use the `select` function which will assert and/// automatically setup tracking.selection: ?Selection = null,
/// The charset statecharset: CharsetState = .{},
/// The current or most recent protected mode. Once a protection mode is/// set, this will never become "off" again until the screen is reset./// The current state of whether protection attributes should be set is/// set on the Cell pen; this is only used to determine the most recent/// protection mode since some sequences such as ECH depend on this.protected_mode: ansi.ProtectedMode = .off,
/// The kitty keyboard settings.kitty_keyboard: kitty.KeyFlagStack = .{},
/// Kitty graphics protocol state.kitty_images: if (build_options.kitty_graphics) kitty.graphics.ImageStorageelse struct {} = .{},
/// Semantic prompt (OSC133) state.semantic_prompt: SemanticPrompt = .disabled,
/// Dirty flags for the renderer.dirty: Dirty = .{},
/// See Terminal.Dirty. This behaves the same way.pub const Dirty = packed struct { /// Set when the selection is set or unset, regardless of if the /// selection is changed or not. selection: bool = false,
/// When an OSC8 hyperlink is hovered, we set the full screen as dirty /// because links can span multiple lines. hyperlink_hover: bool = false,};
pub const SemanticPrompt = struct { /// This is flipped to true when any sort of semantic content is /// seen. In particular, this is set to true only when a `prompt` type /// is ever set on our cursor. /// /// This is used to optimize away semantic content operations if we know /// we've never seen them. seen: bool,
/// This is set on any `cl` or `click_events` option set on the /// most recent OSC 133 commands to specify how click handling in a /// prompt is handling. click: SemanticClick,
pub const disabled: SemanticPrompt = .{ .seen = false, .click = .none, };
pub const SemanticClick = union(enum) { none, click_events: osc.semantic_prompt.ClickEvents, cl: osc.semantic_prompt.Click, };};
/// The cursor position and style.pub const Cursor = struct { // The x/y position within the active area. x: size.CellCountInt = 0, y: size.CellCountInt = 0,
/// The visual style of the cursor. This defaults to block because /// it has to default to something, but users of this struct are /// encouraged to set their own default. cursor_style: CursorStyle = .block,
/// The "last column flag (LCF)" as its called. If this is set then the /// next character print will force a soft-wrap. pending_wrap: bool = false,
/// The protected mode state of the cursor. If this is true then /// all new characters printed will have the protected state set. protected: bool = false,
/// The currently active style. This is the concrete style value /// that should be kept up to date. The style ID to use for cell writing /// is below. style: style.Style = .{},
/// The currently active style ID. The style is page-specific so when /// we change pages we need to ensure that we update that page with /// our style when used. style_id: style.Id = style.default_id,
/// The hyperlink ID that is currently active for the cursor. A value /// of zero means no hyperlink is active. (Implements OSC8, saying that /// so code search can find it.). hyperlink_id: hyperlink.Id = 0,
/// This is the implicit ID to use for hyperlinks that don't specify /// an ID. We do an overflowing add to this so repeats can technically /// happen with carefully crafted inputs but for real workloads its /// highly unlikely -- and the fix is for the TUI program to use explicit /// IDs. hyperlink_implicit_id: size.OffsetInt = 0,
/// Heap-allocated hyperlink state so that we can recreate it when /// the cursor page pin changes. We can't get it from the old screen /// state because the page may be cleared. This is heap allocated /// because its most likely null. hyperlink: ?*hyperlink.Hyperlink = null,
/// The current semantic content type for the cursor that will be /// applied to any newly written cells. semantic_content: pagepkg.Cell.SemanticContent = .output, semantic_content_clear_eol: bool = false,
/// The pointers into the page list where the cursor is currently /// located. This makes it faster to move the cursor. page_pin: *PageList.Pin, page_row: *pagepkg.Row, page_cell: *pagepkg.Cell,
pub fn deinit(self: *Cursor, alloc: Allocator) void { if (self.hyperlink) |link| { link.deinit(alloc); alloc.destroy(link); } }};
/// Saved cursor state.pub const SavedCursor = struct { x: size.CellCountInt, y: size.CellCountInt, style: style.Style, protected: bool, pending_wrap: bool, origin: bool, charset: CharsetState,};
/// State required for all charset operations.pub const CharsetState = struct { /// The list of graphical charsets by slot charsets: CharsetArray = .{},
/// GL is the slot to use when using a 7-bit printable char (up to 127) /// GR used for 8-bit printable chars. gl: charsets.Slots = .G0, gr: charsets.Slots = .G2,
/// Single shift where a slot is used for exactly one char. single_shift: ?charsets.Slots = null,
/// An array to map a charset slot to a lookup table. /// /// We use this bespoke struct instead of `std.EnumArray` because /// accessing these slots is very performance critical since it's /// done for every single print. This benchmarks faster. const CharsetArray = struct { g0: charsets.Charset = .utf8, g1: charsets.Charset = .utf8, g2: charsets.Charset = .utf8, g3: charsets.Charset = .utf8,
pub inline fn get( self: *const CharsetArray, slot: charsets.Slots, ) charsets.Charset { return switch (slot) { .G0 => self.g0, .G1 => self.g1, .G2 => self.g2, .G3 => self.g3, }; }
pub inline fn set( self: *CharsetArray, slot: charsets.Slots, charset: charsets.Charset, ) void { switch (slot) { .G0 => self.g0 = charset, .G1 => self.g1 = charset, .G2 => self.g2 = charset, .G3 => self.g3 = charset, } } };};
pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt,
/// The maximum size of scrollback in bytes. Zero means unlimited. Any /// other value will be clamped to support a minimum of the active area. max_scrollback: usize = 0,
/// The total storage limit for Kitty images in bytes for this /// screen. Kitty image storage is per-screen. kitty_image_storage_limit: usize = switch (build_options.artifact) { .ghostty => 320 * 1000 * 1000, // 320MB .lib => 10 * 1000 * 1000, // 10MB },
/// The limits for what medium types are allowed for Kitty image loading. kitty_image_loading_limits: if (build_options.kitty_graphics) kitty.graphics.LoadingImage.Limits else void = if (build_options.kitty_graphics) .direct else {},
/// A simple, default terminal. If you rely on specific dimensions or /// scrollback (or lack of) then do not use this directly. This is just /// for callers that need some defaults. pub const default: Options = .{ .cols = 80, .rows = 24, .max_scrollback = 0, };};
/// Initialize a new screen.////// max_scrollback is the amount of scrollback to keep in bytes. This/// will be rounded UP to the nearest page size because our minimum allocation/// size is that anyways.////// If max scrollback is 0, then no scrollback is kept at all.pub fn init( alloc: Allocator, opts: Options,) Allocator.Error!Screen { // Initialize our backing pages. var pages = try PageList.init( alloc, opts.cols, opts.rows, opts.max_scrollback, ); errdefer pages.deinit();
// Create our tracked pin for the cursor. const page_pin = try pages.trackPin(.{ .node = pages.pages.first.? }); errdefer pages.untrackPin(page_pin); const page_rac = page_pin.rowAndCell();
var result: Screen = .{ .alloc = alloc, .pages = pages, .no_scrollback = opts.max_scrollback == 0, .cursor = .{ .x = 0, .y = 0, .page_pin = page_pin, .page_row = page_rac.row, .page_cell = page_rac.cell, }, };
if (comptime build_options.kitty_graphics) { // This can't fail because the storage is always empty at this point // and the only fail-able case is that we have to evict images. result.kitty_images.setLimit( alloc, &result, opts.kitty_image_storage_limit, ) catch unreachable; result.kitty_images.image_limits = opts.kitty_image_loading_limits; }
return result;}
pub fn deinit(self: *Screen) void { if (comptime build_options.kitty_graphics) { self.kitty_images.deinit(self.alloc, self); } self.cursor.deinit(self.alloc); self.pages.deinit();}
/// Assert that the screen is in a consistent state. This doesn't check/// all pages in the page list because that is SO SLOW even just for/// tests. This only asserts the screen specific data so callers should/// ensure they're also calling page integrity checks if necessary.pub fn assertIntegrity(self: *const Screen) void { if (build_options.slow_runtime_safety) { // We don't run integrity checks on Valgrind because its soooooo slow, // Valgrind is our integrity checker, and we run these during unit // tests (non-Valgrind) anyways so we're verifying anyways. if (std.valgrind.runningOnValgrind() > 0) return;
assert(self.cursor.x < self.pages.cols); assert(self.cursor.y < self.pages.rows);
// Our cursor x/y should always match the pin. If this doesn't // match then it indicates that the tracked pin moved and we didn't // account for it by either calling cursorReload or manually // adjusting. const pt: point.Point = self.pages.pointFromPin( .active, self.cursor.page_pin.*, ) orelse unreachable; assert(self.cursor.x == pt.active.x); assert(self.cursor.y == pt.active.y); }}
/// Reset the screen according to the logic of a DEC RIS sequence.////// - Clears the screen and attempts to reclaim memory./// - Moves the cursor to the top-left./// - Clears any cursor state: style, hyperlink, etc./// - Resets the charset/// - Clears the selection/// - Deletes all Kitty graphics/// - Resets Kitty Keyboard settings/// - Disables protection mode///pub fn reset(self: *Screen) void { // Reset our pages self.pages.reset();
// The above reset preserves tracked pins so we can still use // our cursor pin, which should be at the top-left already. const cursor_pin: *PageList.Pin = self.cursor.page_pin; assert(cursor_pin.node == self.pages.pages.first.?); assert(cursor_pin.x == 0); assert(cursor_pin.y == 0); const cursor_rac = cursor_pin.rowAndCell(); self.cursor.deinit(self.alloc); self.cursor = .{ .page_pin = cursor_pin, .page_row = cursor_rac.row, .page_cell = cursor_rac.cell, };
if (comptime build_options.kitty_graphics) { // Reset kitty graphics storage self.kitty_images.deinit(self.alloc, self); self.kitty_images = .{ .dirty = true }; }
// Reset our basic state self.saved_cursor = null; self.charset = .{}; self.kitty_keyboard = .{}; self.protected_mode = .off; self.semantic_prompt = .disabled; self.clearSelection();}
/// Clone the screen.////// This will copy:////// - Screen dimensions/// - Screen data (cell state, etc.) for the region////// Anything not mentioned above is NOT copied. Some of this is for/// very good reason:////// - Kitty images have a LOT of data. This is not efficient to copy./// Use a lock and access the image data. The dirty bit is there for/// a reason./// - Cursor location can be expensive to calculate with respect to the/// specified region. It is faster to grab the cursor from the old/// screen and then move it to the new screen./// - Current hyperlink cursor state has heap allocations. Since clone/// is only for read-only operations, it is better to not have any/// hyperlink state. Note that already-written hyperlinks are cloned.////// If not mentioned above, then there isn't a specific reason right now/// to not copy some data other than we probably didn't need it and it/// isn't necessary for screen coherency.////// Other notes:////// - The viewport will always be set to the active area of the new/// screen. This is the bottom "rows" rows./// - If the clone region is smaller than a viewport area, blanks will/// be filled in at the bottom.///pub fn clone( self: *const Screen, alloc: Allocator, top: point.Point, bot: ?point.Point,) !Screen { // Create a tracked pin remapper for our selection and cursor. Note // that we may want to expose this generally in the future but at the // time of doing this we don't need to. var pin_remap = PageList.Clone.TrackedPinsRemap.init(alloc); defer pin_remap.deinit();
var pages = try self.pages.clone(alloc, .{ .top = top, .bot = bot, .tracked_pins = &pin_remap, }); errdefer pages.deinit();
// Find our cursor. If the cursor isn't in the cloned area, we move it // to the top-left arbitrarily because a screen must have SOME cursor. const cursor: Cursor = cursor: { if (pin_remap.get(self.cursor.page_pin)) |p| remap: { const page_rac = p.rowAndCell(); const pt = pages.pointFromPin(.active, p.*) orelse break :remap; break :cursor .{ .x = @intCast(pt.active.x), .y = @intCast(pt.active.y), .page_pin = p, .page_row = page_rac.row, .page_cell = page_rac.cell, }; }
const page_pin = try pages.trackPin(.{ .node = pages.pages.first.? }); const page_rac = page_pin.rowAndCell(); break :cursor .{ .x = 0, .y = 0, .page_pin = page_pin, .page_row = page_rac.row, .page_cell = page_rac.cell, }; };
// Preserve our selection if we have one. const sel: ?Selection = if (self.selection) |sel| sel: { assert(sel.tracked());
const ordered: struct { tl: *Pin, br: *Pin, } = switch (sel.order(self)) { .forward, .mirrored_forward => .{ .tl = sel.bounds.tracked.start, .br = sel.bounds.tracked.end, }, .reverse, .mirrored_reverse => .{ .tl = sel.bounds.tracked.end, .br = sel.bounds.tracked.start, }, };
const start_pin = pin_remap.get(ordered.tl) orelse start: { // No start means it is outside the cloned area.
// If we have no end pin then either // (1) our whole selection is outside the cloned area or // (2) our cloned area is within the selection if (pin_remap.get(ordered.br) == null) { // We check if the selection bottom right pin is above // the cloned area or if the top left pin is below the // cloned area, in either of these cases it means that // the selection is fully out of bounds, so we have no // selection in the cloned area and break out now. const clone_top = self.pages.pin(top) orelse break :sel null; const clone_top_y = self.pages.pointFromPin( .screen, clone_top, ).?.screen.y; if (self.pages.pointFromPin( .screen, ordered.br.*, ).?.screen.y < clone_top_y) break :sel null; if (self.pages.pointFromPin( .screen, ordered.tl.*, ).?.screen.y > clone_top_y) break :sel null; }
// We move the top pin back in bounds to the top row. break :start try pages.trackPin(.{ .node = pages.pages.first.?, .x = if (sel.rectangle) ordered.tl.x else 0, }); };
// If we got to this point it means that the selection is not // fully out of bounds, so we move the bottom right pin back // in bounds if it isn't already. const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ .node = pages.pages.last.?, .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, .y = pages.pages.last.?.data.size.rows - 1, });
break :sel .{ .bounds = .{ .tracked = .{ .start = start_pin, .end = end_pin, } }, .rectangle = sel.rectangle, }; } else null;
const result: Screen = .{ .alloc = alloc, .pages = pages, .no_scrollback = self.no_scrollback, .cursor = cursor, .selection = sel, .dirty = self.dirty, }; result.assertIntegrity(); return result;}pub fn increaseCapacity( self: *Screen, node: *PageList.List.Node, adjustment: ?PageList.IncreaseCapacity,) PageList.IncreaseCapacityError!*PageList.List.Node { // If the page being modified isn't our cursor page then // this is a quick operation because we have no additional // accounting. We have to do this check here BEFORE calling // increaseCapacity because increaseCapacity will update all // our tracked pins (including our cursor). if (node != self.cursor.page_pin.node) return try self.pages.increaseCapacity( node, adjustment, );
// We're modifying the cursor page. When we increase the // capacity below it will be short the ref count on our // current style and hyperlink, so we need to init those. const new_node = try self.pages.increaseCapacity(node, adjustment); const new_page: *Page = &new_node.data;
// Re-add the style, if the page somehow doesn't have enough // memory to add it, we emit a warning and gracefully degrade // to the default style for the cursor. if (self.cursor.style_id != style.default_id) { self.cursor.style_id = new_page.styles.add( new_page.memory, self.cursor.style, ) catch |err| id: { // TODO: Should we increase the capacity further in this case? log.warn( "(Screen.increaseCapacity) Failed to add cursor style back to page, err={}", .{err}, );
// Reset the cursor style. self.cursor.style = .{}; break :id style.default_id; }; }
// Re-add the hyperlink, if the page somehow doesn't have enough // memory to add it, we emit a warning and gracefully degrade to // no hyperlink. if (self.cursor.hyperlink) |link| { // So we don't attempt to free any memory in the replaced page. self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null;
// Re-add self.startHyperlinkOnce(link.*) catch |err| { // TODO: Should we increase the capacity further in this case? log.warn( "(Screen.increaseCapacity) Failed to add cursor hyperlink back to page, err={}", .{err}, ); };
// Remove our old link link.deinit(self.alloc); self.alloc.destroy(link); }
// Reload the cursor information because the pin changed. // So our page row/cell and so on are all off. self.cursorReload();
return new_node;}
pub inline fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { assert(self.cursor.x + n < self.pages.cols); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); return @ptrCast(cell + n);}
pub inline fn cursorCellLeft(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { assert(self.cursor.x >= n); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); return @ptrCast(cell - n);}
pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell { assert(self.cursor.y > 0);
var page_pin = self.cursor.page_pin.up(1).?; page_pin.x = self.pages.cols - 1; const page_rac = page_pin.rowAndCell(); return page_rac.cell;}
/// Move the cursor right. This is a specialized function that is very fast/// if the caller can guarantee we have space to move right (no wrapping).pub fn cursorRight(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.x + n < self.pages.cols); defer self.assertIntegrity();
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); self.cursor.page_cell = @ptrCast(cell + n); self.cursor.page_pin.x += n; self.cursor.x += n;}
/// Move the cursor left.pub fn cursorLeft(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.x >= n); defer self.assertIntegrity();
const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); self.cursor.page_cell = @ptrCast(cell - n); self.cursor.page_pin.x -= n; self.cursor.x -= n;}
/// Move the cursor up.////// Precondition: The cursor is not at the top of the screen.pub fn cursorUp(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.y >= n); defer self.assertIntegrity();
self.cursor.y -= n; // Must be set before cursorChangePin self.cursorChangePin(self.cursor.page_pin.up(n).?); const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;}
pub fn cursorRowUp(self: *Screen, n: size.CellCountInt) *pagepkg.Row { assert(self.cursor.y >= n); defer self.assertIntegrity();
const page_pin = self.cursor.page_pin.up(n).?; const page_rac = page_pin.rowAndCell(); return page_rac.row;}
/// Move the cursor down.////// Precondition: The cursor is not at the bottom of the screen.pub fn cursorDown(self: *Screen, n: size.CellCountInt) void { assert(self.cursor.y + n < self.pages.rows); defer self.assertIntegrity();
self.cursor.y += n; // Must be set before cursorChangePin
// We move the offset into our page list to the next row and then // get the pointers to the row/cell and set all the cursor state up. self.cursorChangePin(self.cursor.page_pin.down(n).?); const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;}
/// Move the cursor to some absolute horizontal position.pub fn cursorHorizontalAbsolute(self: *Screen, x: size.CellCountInt) void { assert(x < self.pages.cols); defer self.assertIntegrity();
self.cursor.page_pin.x = x; const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_cell = page_rac.cell; self.cursor.x = x;}
/// Move the cursor to some absolute position.pub fn cursorAbsolute(self: *Screen, x: size.CellCountInt, y: size.CellCountInt) void { assert(x < self.pages.cols); assert(y < self.pages.rows); defer self.assertIntegrity();
var page_pin = if (y < self.cursor.y) self.cursor.page_pin.up(self.cursor.y - y).? else if (y > self.cursor.y) self.cursor.page_pin.down(y - self.cursor.y).? else self.cursor.page_pin.*; page_pin.x = x; self.cursor.x = x; // Must be set before cursorChangePin self.cursor.y = y; self.cursorChangePin(page_pin); const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;}
/// Reloads the cursor pointer information into the screen. This is expensive/// so it should only be done in cases where the pointers are invalidated/// in such a way that its difficult to recover otherwise.pub fn cursorReload(self: *Screen) void { defer self.assertIntegrity();
// Our tracked pin is ALWAYS accurate, so we derive the active // point from the pin. If this returns null it means our pin // points outside the active area. In that case, we update the // pin to be the top-left. const pt: point.Point = self.pages.pointFromPin( .active, self.cursor.page_pin.*, ) orelse reset: { const pin = self.pages.pin(.{ .active = .{} }).?; self.cursor.page_pin.* = pin; break :reset self.pages.pointFromPin(.active, pin).?; };
self.cursor.x = @intCast(pt.active.x); self.cursor.y = @intCast(pt.active.y); const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;
// If we have a style, we need to ensure it is in the page because this // method may also be called after a page change. if (self.cursor.style_id != style.default_id) { self.manualStyleUpdate() catch |err| { // This failure should not happen because manualStyleUpdate // handles page splitting, overflow, and more. This should only // happen if we're out of RAM. In this case, we'll just degrade // gracefully back to the default style. log.err("failed to update style on cursor reload err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; }; }}
/// Scroll the active area and keep the cursor at the bottom of the screen./// This is a very specialized function but it keeps it fast.pub fn cursorDownScroll(self: *Screen) !void { assert(self.cursor.y == self.pages.rows - 1); defer self.assertIntegrity();
if (comptime build_options.kitty_graphics) { // Scrolling dirties the images because it updates their placements pins. self.kitty_images.dirty = true; }
// If we have no scrollback, then we shift all our rows instead. if (self.no_scrollback) { // If we have a single-row screen, we have no rows to shift // so our cursor is in the correct place we just have to clear // the cells. if (self.pages.rows == 1) { const page: *Page = &self.cursor.page_pin.node.data; self.clearCells( page, self.cursor.page_row, page.getCells(self.cursor.page_row), ); self.cursorMarkDirty(); } else { // The call to `eraseRow` will move the tracked cursor pin up by one // row, but we don't actually want that, so we keep the old pin and // put it back after calling `eraseRow`. const old_pin = self.cursor.page_pin.*;
// eraseRow will shift everything below it up. try self.pages.eraseRow(.{ .active = .{} });
// Note we don't need to mark anything dirty in this branch // because eraseRow will mark all the rotated rows as dirty // in the entire page.
// We don't use `cursorChangePin` here because we aren't // actually changing the pin, we're keeping it the same. self.cursor.page_pin.* = old_pin;
// We do, however, need to refresh the cached page row // and cell, because `eraseRow` will have moved the row. const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; } } else { const old_pin = self.cursor.page_pin.*;
// Grow our pages by one row. The PageList will handle if we need to // allocate, prune scrollback, whatever. _ = try self.pages.grow();
self.cursorChangePin(new_pin: { // We do this all in a block here because referencing this pin // after cursorChangePin is unsafe, and we want to keep it out // of scope.
// If our pin page change it means that the page that the pin // was on was pruned. In this case, grow() moves the pin to // the top-left of the new page. This effectively moves it by // one already, we just need to fix up the x value. const page_pin = if (old_pin.node == self.cursor.page_pin.node) self.cursor.page_pin.down(1).? else reuse: { var pin = self.cursor.page_pin.*; pin.x = self.cursor.x; break :reuse pin; };
// These assertions help catch some pagelist math errors. Our // x/y should be unchanged after the grow. if (build_options.slow_runtime_safety) { const active = self.pages.pointFromPin( .active, page_pin, ).?.active; assert(active.x == self.cursor.x); assert(active.y == self.cursor.y); }
break :new_pin page_pin; }); const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;
// Our new row is always dirty self.cursorMarkDirty();
// Clear the new row so it gets our bg color. We only do this // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { const page: *Page = &self.cursor.page_pin.node.data; self.clearCells( page, self.cursor.page_row, page.getCells(self.cursor.page_row), ); } }
if (self.cursor.style_id != style.default_id) { // The newly created line needs to be styled according to // the bg color if it is set. if (self.cursor.style.bgCell()) |blank_cell| { const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); const cells = cell_current - self.cursor.x; @memset(cells[0..self.pages.cols], blank_cell); } }}
/// This scrolls the active area at and above the cursor./// The lines below the cursor are not scrolled.pub fn cursorScrollAbove(self: *Screen) !void { // We unconditionally mark the cursor row as dirty here because // the cursor always changes page rows inside this function, and // when that happens it can mean the text in the old row needs to // be re-shaped because the cursor splits runs to break ligatures. self.cursorMarkDirty();
// If the cursor is on the bottom of the screen, its faster to use // our specialized function for that case. if (self.cursor.y == self.pages.rows - 1) { return try self.cursorDownScroll(); }
defer self.assertIntegrity();
// Logic below assumes we always have at least one row that isn't moving assert(self.cursor.y < self.pages.rows - 1);
// Explanation: // We don't actually move everything that's at or above the cursor row, // since this would require us to shift up our ENTIRE scrollback, which // would be ridiculously expensive. Instead, we insert a new row at the // end of the pagelist (`grow()`), and move everything BELOW the cursor // DOWN by one row. This has the same practical result but it's a whole // lot cheaper in 99% of cases.
const old_pin = self.cursor.page_pin.*; if (try self.pages.grow()) |_| { try self.cursorScrollAboveRotate(); } else { // In this case, it means grow() didn't allocate a new page.
if (self.cursor.page_pin.node == self.pages.pages.last) { // If we're on the last page we can do a very fast path because // all the rows we need to move around are within a single page.
// Note: we don't need to call cursorChangePin here because // the pin page is the same so there is no accounting to do // for styles or any of that. assert(old_pin.node == self.cursor.page_pin.node); self.cursor.page_pin.* = self.cursor.page_pin.down(1).?;
const pin = self.cursor.page_pin; const page: *Page = &self.cursor.page_pin.node.data;
// Rotate the rows so that the newly created empty row is at the // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. var rows = page.rows.ptr(page.memory.ptr); fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]);
// Mark the whole page as dirty. // // Technically we only need to mark from the cursor row to the // end but this is a hot function, so we want to minimize work. page.dirty = true;
// Setup our cursor caches after the rotation so it points to the // correct data const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; } else { // We didn't grow pages but our cursor isn't on the last page. // In this case we need to do more work because we need to copy // elements between pages. // // An example scenario of this is shown below: // // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4302 |1A00000000| | 0 // 4303 |2B00000000| | 1 // :^ : : = PIN 0 // 4304 |3C00000000| | 2 // +----------+ : // +----------+ : = PAGE 1 // 0 |4D00000000| | 3 // 1 |5E00000000| | 4 // +----------+ : // +-------------+ try self.cursorScrollAboveRotate(); } }
if (self.cursor.style_id != style.default_id) { // The newly created line needs to be styled according to // the bg color if it is set. if (self.cursor.style.bgCell()) |blank_cell| { const cell_current: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); const cells = cell_current - self.cursor.x; @memset(cells[0..self.pages.cols], blank_cell); } }}
fn cursorScrollAboveRotate(self: *Screen) !void { self.cursorChangePin(self.cursor.page_pin.down(1).?);
// Go through each of the pages following our pin, shift all rows // down by one, and copy the last row of the previous page. var current = self.pages.pages.last.?; while (current != self.cursor.page_pin.node) : (current = current.prev.?) { const prev = current.prev.?; const prev_page = &prev.data; const cur_page = ¤t.data; const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
// Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ] fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]);
// Copy the last row of the previous page to the top of current. try cur_page.cloneRowFrom( prev_page, &cur_rows[0], &prev_rows[prev_page.size.rows - 1], );
// Mark dirty on the page, since we are dirtying all rows with this. cur_page.dirty = true; }
// Our current is our cursor page, we need to rotate down from // our cursor and clear our row. assert(current == self.cursor.page_pin.node); const cur_page = ¤t.data; const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]); self.clearCells( cur_page, &cur_rows[self.cursor.page_pin.y], cur_page.getCells(&cur_rows[self.cursor.page_pin.y]), );
// Mark the whole page as dirty. // // Technically we only need to mark from the cursor row to the // end but this is a hot function, so we want to minimize work. cur_page.dirty = true;
// Setup cursor cache data after all the rotations so our // row is valid. const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell;}
/// Move the cursor down if we're not at the bottom of the screen. Otherwise/// scroll. Currently only used for testing.inline fn cursorDownOrScroll(self: *Screen) !void { if (self.cursor.y + 1 < self.pages.rows) { self.cursorDown(1); } else { try self.cursorDownScroll(); }}
/// Copy another cursor. The cursor can be on any screen but the x/y/// must be within our screen bounds.pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { /// Copy the hyperlink from the other cursor. If not set, this will /// clear our current hyperlink. hyperlink: bool = true,}) !void { assert(other.x < self.pages.cols); assert(other.y < self.pages.rows);
// End any currently active hyperlink on our cursor. self.endHyperlink();
const old = self.cursor; self.cursor = other; errdefer self.cursor = old;
// Keep our old style ID so it can be properly cleaned up below. self.cursor.style_id = old.style_id;
// Hyperlinks will be managed separately below. self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null;
// Keep our old page pin and X/Y because: // 1. The old style will need to be cleaned up from the page it's from. // 2. The new position navigated to by `cursorAbsolute` needs to be in our // own screen. self.cursor.page_pin = old.page_pin; self.cursor.x = old.x; self.cursor.y = old.y;
// Call manual style update in order to clean up our old style, if we have // one, and also to load the style from the other cursor, if it had one. try self.manualStyleUpdate();
// Move to the correct location to match the other cursor. self.cursorAbsolute(other.x, other.y);
// If the other cursor had a hyperlink, add it to ours. if (opts.hyperlink and other.hyperlink_id != 0) { // Get the hyperlink from the other cursor's page. const other_page = &other.page_pin.node.data; const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id);
const uri = other_link.uri.slice(other_page.memory); const id_ = switch (other_link.id) { .explicit => |id| id.slice(other_page.memory), .implicit => null, };
// And it to our cursor. self.startHyperlink(uri, id_) catch |err| { // This shouldn't happen because startHyperlink should handle // resizing. This only happens if we're truly out of RAM. Degrade // to forgetting the hyperlink. log.err("failed to update hyperlink on cursor change err={}", .{err}); }; }}
/// Always use this to write to cursor.page_pin.*.////// This specifically handles the case when the new pin is on a different/// page than the old AND we have a style or hyperlink set. In that case,/// we must release our old one and insert the new one, since styles are/// stored per-page.////// Note that this can change the cursor pin AGAIN if the process of/// setting up our cursor forces a capacity adjustment of the underlying/// cursor page, so any references to the page pin should be re-read/// from `self.cursor.page_pin` after calling this.inline fn cursorChangePin(self: *Screen, new: Pin) void { // Moving the cursor affects text run splitting (ligatures) so // we must mark the old and new page dirty. We do this as long // as the pins are not equal if (!self.cursor.page_pin.eql(new)) { self.cursorMarkDirty(); new.markDirty(); }
// If our pin is on the same page, then we can just update the pin. // We don't need to migrate any state. if (self.cursor.page_pin.node == new.node) { self.cursor.page_pin.* = new; return; }
// If we have an old style then we need to release it from the old page. const old_style_: ?style.Style = if (self.cursor.style_id == style.default_id) null else self.cursor.style; if (old_style_ != null) { // Release the style directly from the old page instead of going through // manualStyleUpdate, because the cursor position may have already been // updated but the pin has not, which would fail integrity checks. const old_page: *Page = &self.cursor.page_pin.node.data; old_page.styles.release(old_page.memory, self.cursor.style_id); self.cursor.style = .{}; self.cursor.style_id = style.default_id; }
// If we have a hyperlink then we need to release it from the old page. if (self.cursor.hyperlink != null) { const old_page: *Page = &self.cursor.page_pin.node.data; old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); }
// Update our pin to the new page self.cursor.page_pin.* = new;
// On the new page, we need to migrate our style if (old_style_) |old_style| { self.cursor.style = old_style; self.manualStyleUpdate() catch |err| { // This failure should not happen because manualStyleUpdate // handles page splitting, overflow, and more. This should only // happen if we're out of RAM. In this case, we'll just degrade // gracefully back to the default style. log.err("failed to update style on cursor change err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; }; }
// On the new page, we need to migrate our hyperlink if (self.cursor.hyperlink) |link| { // So we don't attempt to free any memory in the replaced page. self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null;
// Re-add self.startHyperlink(link.uri, switch (link.id) { .explicit => |v| v, .implicit => null, }) catch |err| { // This shouldn't happen because startHyperlink should handle // resizing. This only happens if we're truly out of RAM. Degrade // to forgetting the hyperlink. log.err("failed to update hyperlink on cursor change err={}", .{err}); };
// Remove our old link link.deinit(self.alloc); self.alloc.destroy(link); }}
/// Mark the cursor position as dirty./// TODO: testpub inline fn cursorMarkDirty(self: *Screen) void { self.cursor.page_row.dirty = true;}
/// Reset the cursor row's soft-wrap state and the cursor's pending wrap./// Also handles clearing the spacer head on the cursor row and resetting/// the wrap_continuation flag on the next row if necessary.////// NOTE(qwerasd): This method is not scrolling region aware, and cannot be/// since it's on Screen not Terminal. This needs to be addressed down the/// line. Not an extremely urgent issue since it's an edge case of an edge/// case, but not ideal.pub fn cursorResetWrap(self: *Screen) void { // Reset the cursor's pending wrap state self.cursor.pending_wrap = false;
const page_row = self.cursor.page_row;
if (!page_row.wrap) return;
// This row does not wrap and the next row is not wrapped to page_row.wrap = false;
if (self.cursor.page_pin.down(1)) |next_row| { next_row.rowAndCell().row.wrap_continuation = false; }
// If the last cell in the row is a spacer head we need to clear it. const cells = self.cursor.page_pin.cells(.all); const cell = cells[self.cursor.page_pin.node.data.size.cols - 1]; if (cell.wide == .spacer_head) { self.clearCells( &self.cursor.page_pin.node.data, page_row, cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1], ); }}
/// Options for scrolling the viewport of the terminal grid. The reason/// we have this in addition to PageList.Scroll is because we have additional/// scroll behaviors that are not part of the PageList.Scroll enum.pub const Scroll = union(enum) { /// For all of these, see PageList.Scroll. active, top, pin: Pin, row: usize, delta_row: isize, delta_prompt: isize,};
/// Scroll the viewport of the terminal grid.pub inline fn scroll(self: *Screen, behavior: Scroll) void { defer self.assertIntegrity();
if (comptime build_options.kitty_graphics) { // No matter what, scrolling marks our image state as dirty since // it could move placements. If there are no placements or no images // this is still a very cheap operation. self.kitty_images.dirty = true; }
switch (behavior) { .active => self.pages.scroll(.{ .active = {} }), .top => self.pages.scroll(.{ .top = {} }), .pin => |p| self.pages.scroll(.{ .pin = p }), .row => |v| self.pages.scroll(.{ .row = v }), .delta_row => |v| self.pages.scroll(.{ .delta_row = v }), .delta_prompt => |v| self.pages.scroll(.{ .delta_prompt = v }), }}
/// See PageList.scrollClear. In addition to that, we reset the cursor/// to be on top.pub inline fn scrollClear(self: *Screen) !void { defer self.assertIntegrity();
try self.pages.scrollClear(); self.cursorReload();
if (comptime build_options.kitty_graphics) { // No matter what, scrolling marks our image state as dirty since // it could move placements. If there are no placements or no images // this is still a very cheap operation. self.kitty_images.dirty = true; }}
/// Returns true if the viewport is scrolled to the bottom of the screen.pub inline fn viewportIsBottom(self: Screen) bool { return self.pages.viewport == .active;}
/// Erase the region specified by tl and br, inclusive. This will physically/// erase the rows meaning the memory will be reclaimed (if the underlying/// page is empty) and other rows will be shifted up.pub inline fn eraseHistory( self: *Screen, bl: ?point.Point,) void { defer self.assertIntegrity(); self.pages.eraseHistory(bl); self.cursorReload();}
pub inline fn eraseActive( self: *Screen, y: size.CellCountInt,) void { defer self.assertIntegrity(); self.pages.eraseActive(y); self.cursorReload();}
// Clear the region specified by tl and bl, inclusive. Cleared cells are// colored with the current style background color. This will clear all// cells in the rows.//// If protected is true, the protected flag will be respected and only// unprotected cells will be cleared. Otherwise, all cells will be cleared.pub fn clearRows( self: *Screen, tl: point.Point, bl: ?point.Point, protected: bool,) void { defer self.assertIntegrity();
var it = self.pages.pageIterator(.right_down, tl, bl); while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_offset = row.cells; const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); const cells = cells_multi[0..self.pages.cols];
// Clear all cells if (protected) { self.clearUnprotectedCells(&chunk.node.data, row, cells); // We need to preserve other row attributes since we only // cleared unprotected cells. row.cells = cells_offset; } else { self.clearCells(&chunk.node.data, row, cells); row.* = .{ .cells = cells_offset }; }
row.dirty = true; } }}
/// Clear the cells with the blank cell.////// This takes care to handle cleaning up graphemes and styles.pub fn clearCells( self: *Screen, page: *Page, row: *Row, cells: []Cell,) void { // This whole operation does unsafe things, so we just want to assert // the end state. page.pauseIntegrityChecks(true); defer { page.pauseIntegrityChecks(false); page.assertIntegrity(); self.assertIntegrity(); }
if (comptime std.debug.runtime_safety) { // Our row and cells should be within the page. const page_rows = page.rows.ptr(page.memory.ptr); assert(@intFromPtr(row) >= @intFromPtr(&page_rows[0])); assert(@intFromPtr(row) <= @intFromPtr(&page_rows[page.size.rows - 1]));
const row_cells = page.getCells(row); assert(@intFromPtr(&cells[0]) >= @intFromPtr(&row_cells[0])); assert(@intFromPtr(&cells[cells.len - 1]) <= @intFromPtr(&row_cells[row_cells.len - 1])); }
// If we have managed memory (styles, graphemes, or hyperlinks) // in this row then we go cell by cell and clear them if present. if (row.grapheme) { for (cells) |*cell| { if (cell.hasGrapheme()) page.clearGrapheme(cell); }
// If we have no left/right scroll region we can be sure // that we've cleared all the graphemes, so we clear the // flag, otherwise we ask the page to update the flag. if (cells.len == self.pages.cols) { row.grapheme = false; } else { page.updateRowGraphemeFlag(row); } }
if (row.hyperlink) { for (cells) |*cell| { if (cell.hyperlink) page.clearHyperlink(cell); }
// If we have no left/right scroll region we can be sure // that we've cleared all the hyperlinks, so we clear the // flag, otherwise we ask the page to update the flag. if (cells.len == self.pages.cols) { row.hyperlink = false; } else { page.updateRowHyperlinkFlag(row); } }
if (row.styled) { for (cells) |*cell| { if (cell.hasStyling()) page.styles.release(page.memory, cell.style_id); }
// If we have no left/right scroll region we can be sure // that we've cleared all the styles, so we clear the // flag, otherwise we ask the page to update the flag. if (cells.len == self.pages.cols) { row.styled = false; } else { page.updateRowStyledFlag(row); } }
if (comptime build_options.kitty_graphics) { if (row.kitty_virtual_placeholder and cells.len == self.pages.cols) { for (cells) |c| { if (c.codepoint() == kitty.graphics.unicode.placeholder) { break; } } else row.kitty_virtual_placeholder = false; } }
@memset(cells, self.blankCell());}
/// Clear cells but only if they are not protected.pub fn clearUnprotectedCells( self: *Screen, page: *Page, row: *Row, cells: []Cell,) void { var x0: usize = 0; var x1: usize = 0;
while (x0 < cells.len) clear: { while (cells[x0].protected) { x0 += 1; if (x0 >= cells.len) break :clear; } x1 = x0 + 1; while (x1 < cells.len and !cells[x1].protected) { x1 += 1; } self.clearCells(page, row, cells[x0..x1]); x0 = x1; }
page.assertIntegrity(); self.assertIntegrity();}
/// Clean up boundary conditions where a cell will become discontiguous with/// a neighboring cell because either one of them will be moved and/or cleared.////// For performance reasons this is specialized to operate on the cursor row.////// Handles the boundary between the cell at `x` and the cell at `x - 1`.////// So, for example, when moving a region of cells [a, b] (inclusive), call this/// function with `x = a` and `x = b + 1`. It is okay if `x` is out of bounds by/// 1, this will be interpreted correctly.////// DOES NOT MODIFY ROW WRAP STATE! See `cursorResetWrap` for that.////// The following boundary conditions are handled:////// - `x - 1` is a wide character and `x` is a spacer tail:/// o Both cells will be cleared./// o If `x - 1` is the start of the row and was wrapped from a previous row/// then the previous row is checked for a spacer head, which is cleared if/// present.////// - `x == 0` and is a wide character:/// o If the row is a wrap continuation then the previous row will be checked/// for a spacer head, which is cleared if present.////// - `x == cols` and `x - 1` is a spacer head:/// o `x - 1` will be cleared.////// NOTE(qwerasd): This method is not scrolling region aware, and cannot be/// since it's on Screen not Terminal. This needs to be addressed down the/// line. Not an extremely urgent issue since it's an edge case of an edge/// case, but not ideal.pub fn splitCellBoundary( self: *Screen, x: size.CellCountInt,) void { const page = &self.cursor.page_pin.node.data;
page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false);
const cols = self.cursor.page_pin.node.data.size.cols;
// `x` may be up to an INCLUDING `cols`, since that signifies splitting // the boundary to the right of the final cell in the row. assert(x <= cols);
// [ A B C D E F|] // ^ Boundary between final cell and row end. if (x == cols) { if (!self.cursor.page_row.wrap) return;
const cells = self.cursor.page_pin.cells(.all);
// Spacer head at end of wrapped row. if (cells[cols - 1].wide == .spacer_head) { self.clearCells( page, self.cursor.page_row, cells[cols - 1 ..][0..1], ); }
return; }
// [|A B C D E F ] // ^ Boundary between first cell and row start. // // OR // // [ A|B C D E F ] // ^ Boundary between first cell and second cell. // // First cell may be a wrapped wide cell with a spacer // head on the previous row that needs to be cleared. if ((x == 0 or x == 1) and self.cursor.page_row.wrap_continuation) { const cells = self.cursor.page_pin.cells(.all);
// If the first cell in a row is wide the previous row // may have a spacer head which needs to be cleared. if (cells[0].wide == .wide) { if (self.cursor.page_pin.up(1)) |p_row| { const p_rac = p_row.rowAndCell(); const p_cells = p_row.cells(.all); const p_cell = p_cells[p_row.node.data.size.cols - 1]; if (p_cell.wide == .spacer_head) { self.clearCells( &p_row.node.data, p_rac.row, p_cells[p_row.node.data.size.cols - 1 ..][0..1], ); } } } }
// If x is 0 then we're done. if (x == 0) return;
// [ ... X|Y ... ] // ^ Boundary between two cells in the middle of the row. { assert(x > 0); assert(x < cols);
const cells = self.cursor.page_pin.cells(.all);
const left = cells[x - 1]; switch (left.wide) { // There should not be spacer heads in the middle of the row. .spacer_head => unreachable,
// We don't need to do anything for narrow cells or spacer tails. .narrow, .spacer_tail => {},
// A wide char would be split, so must be cleared. .wide => { self.clearCells( page, self.cursor.page_row, cells[x - 1 ..][0..2], ); }, } }}
/// Returns the blank cell to use when doing terminal operations that/// require preserving the bg color.pub inline fn blankCell(self: *const Screen) Cell { if (self.cursor.style_id == style.default_id) return .{}; return self.cursor.style.bgCell() orelse .{};}
pub const Resize = struct { /// The new size to resize to cols: size.CellCountInt, rows: size.CellCountInt,
/// Whether to reflow soft-wrapped text. /// /// This will reflow soft-wrapped text. If the screen size is getting /// smaller and the maximum scrollback size is exceeded, data will be /// lost from the top of the scrollback. reflow: bool = true,
/// Set this to enable prompt redraw on resize. This signals /// that the running program can redraw the prompt if the cursor is /// currently at a prompt. This detects OSC133 prompts lines and clears /// them. If set to `.last`, only the most recent prompt line is cleared. prompt_redraw: osc.semantic_prompt.Redraw = .false,};
/// Resize the screen. The rows or cols can be bigger or smaller.////// If this returns an error, the screen is left in a likely garbage state./// It is very hard to undo this operation without blowing up our memory/// usage. The only way to recover is to reset the screen. The only way/// this really fails is if page allocation is required and fails, which/// probably means the system is in trouble anyways. I'd like to improve this/// in the future but it is not a priority particularly because this scenario/// (resize) is difficult.pub inline fn resize( self: *Screen, opts: Resize,) !void { defer self.assertIntegrity();
if (comptime build_options.kitty_graphics) { // No matter what we mark our image state as dirty self.kitty_images.dirty = true; }
// Release the cursor style while resizing just // in case the cursor ends up on a different page. const cursor_style = self.cursor.style; self.cursor.style = .{}; self.manualStyleUpdate() catch unreachable; defer { // Restore the cursor style. self.cursor.style = cursor_style; self.manualStyleUpdate() catch |err| { // This failure should not happen because manualStyleUpdate // handles page splitting, overflow, and more. This should only // happen if we're out of RAM. In this case, we'll just degrade // gracefully back to the default style. log.err("failed to update style on cursor reload err={}", .{err}); self.cursor.style = .{}; self.cursor.style_id = 0; }; }
// If we have a hyperlink, release it from the old page // and then we need to re-add it to the new page. This needs // to happen because resize below typically reallocates a // new page so the old hyperlink is invalid. const hyperlink_ = self.cursor.hyperlink; if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. var page = &self.cursor.page_pin.node.data; page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null; }
// We need to insert a tracked pin for our saved cursor so we can // modify its X/Y for reflow. const saved_cursor_pin: ?*Pin = saved_cursor: { const sc = self.saved_cursor orelse break :saved_cursor null; const pin = self.pages.pin(.{ .active = .{ .x = sc.x, .y = sc.y, } }) orelse break :saved_cursor null; break :saved_cursor try self.pages.trackPin(pin); }; defer if (saved_cursor_pin) |p| self.pages.untrackPin(p);
// If our cursor is on a prompt or input line, clear it so the shell can // redraw it. This works with OSC 133 semantic prompts. // // We check cursor.semantic_content rather than page_row.semantic_prompt // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't // mark continuation lines with k=s. If the input spans multiple lines and // continuation lines are unmarked, checking only page_row.semantic_prompt // would miss them. By checking semantic_content, we assume that if the // cursor is on anything other than command output, we're at a prompt/input // line and should clear from there. if (opts.prompt_redraw != .false and self.cursor.semantic_content != .output) prompt: { switch (opts.prompt_redraw) { .false => unreachable,
// For `.last`, only clear the current line where the cursor is. // For `.true`, clear all prompt lines starting from the beginning. .last => { const page = &self.cursor.page_pin.node.data; const row = self.cursor.page_row; const cells = page.getCells(row); self.clearCells(page, row, cells); },
.true => { const start = start: { var it = self.cursor.page_pin.promptIterator( .left_up, null, ); break :start it.next() orelse { // This should never happen because promptIterator should always // find a prompt if we already verified our row is some kind of // prompt. log.warn("cursor on prompt line but promptIterator found no prompt", .{}); break :prompt; }; };
// Clear cells from our start down. We replace it with spaces, // and do not physically erase the rows (eraseRows) because the // shell is going to expect this space to be available. var it = start.rowIterator(.right_down, null); while (it.next()) |pin| { const page = &pin.node.data; const row = pin.rowAndCell().row; const cells = page.getCells(row); self.clearCells(page, row, cells); } }, } }
// Perform the resize operation. try self.pages.resize(.{ .rows = opts.rows, .cols = opts.cols, .reflow = opts.reflow, .cursor = .{ .x = self.cursor.x, .y = self.cursor.y, .pin = self.cursor.page_pin, }, });
// If we have no scrollback and we shrunk our rows, we must explicitly // erase our history. This is because PageList always keeps at least // a page size of history. if (self.no_scrollback) { self.pages.eraseHistory(null); }
// If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload();
// If we reflowed a saved cursor, update it. if (saved_cursor_pin) |p| { // This should never fail because a non-null saved_cursor_pin // implies a non-null saved_cursor. const sc = &self.saved_cursor.?; if (self.pages.pointFromPin(.active, p.*)) |pt| { sc.x = @intCast(pt.active.x); sc.y = @intCast(pt.active.y);
// If we had pending wrap set and we're no longer at the end of // the line, we unset the pending wrap and move the cursor to // reflect the correct next position. if (sc.pending_wrap and sc.x != opts.cols - 1) { sc.pending_wrap = false; sc.x += 1; } } else { // I think this can happen if the screen is resized to be // less rows or less cols and our saved cursor moves outside // the active area. In this case, there isn't anything really // reasonable we can do so we just move the cursor to the // top-left. It may be reasonable to also move the cursor to // match the primary cursor. Any behavior is fine since this is // totally unspecified. sc.x = 0; sc.y = 0; sc.pending_wrap = false; } }
// Fix up our hyperlink if we had one. if (hyperlink_) |link| { self.startHyperlink(link.uri, switch (link.id) { .explicit => |v| v, .implicit => null, }) catch |err| { // This shouldn't happen because startHyperlink should handle // resizing. This only happens if we're truly out of RAM. Degrade // to forgetting the hyperlink. log.err("failed to update hyperlink on resize err={}", .{err}); };
// Remove our old link link.deinit(self.alloc); self.alloc.destroy(link); }}
/// Set a style attribute for the current cursor.////// If the style can't be set due to any internal errors (memory-related),/// then this will revert back to the existing style and return an error.pub fn setAttribute( self: *Screen, attr: sgr.Attribute,) PageList.IncreaseCapacityError!void { // If we fail to set our style for any reason, we should revert // back to the old style. If we fail to do that, we revert back to // the default style. const old_style = self.cursor.style; errdefer { self.cursor.style = old_style; self.manualStyleUpdate() catch |err| { log.warn("setAttribute error restoring old style after failure err={}", .{err}); self.cursor.style = .{}; self.manualStyleUpdate() catch unreachable; }; }
switch (attr) { .unset => { self.cursor.style = .{}; },
.bold => { self.cursor.style.flags.bold = true; },
.reset_bold => { // Bold and faint share the same SGR code for this self.cursor.style.flags.bold = false; self.cursor.style.flags.faint = false; },
.italic => { self.cursor.style.flags.italic = true; },
.reset_italic => { self.cursor.style.flags.italic = false; },
.faint => { self.cursor.style.flags.faint = true; },
.underline => |v| { self.cursor.style.flags.underline = v; },
.underline_color => |rgb| { self.cursor.style.underline_color = .{ .rgb = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b, } }; },
.@"256_underline_color" => |idx| { self.cursor.style.underline_color = .{ .palette = idx }; },
.reset_underline_color => { self.cursor.style.underline_color = .none; },
.overline => { self.cursor.style.flags.overline = true; },
.reset_overline => { self.cursor.style.flags.overline = false; },
.blink => { self.cursor.style.flags.blink = true; },
.reset_blink => { self.cursor.style.flags.blink = false; },
.inverse => { self.cursor.style.flags.inverse = true; },
.reset_inverse => { self.cursor.style.flags.inverse = false; },
.invisible => { self.cursor.style.flags.invisible = true; },
.reset_invisible => { self.cursor.style.flags.invisible = false; },
.strikethrough => { self.cursor.style.flags.strikethrough = true; },
.reset_strikethrough => { self.cursor.style.flags.strikethrough = false; },
.direct_color_fg => |rgb| { self.cursor.style.fg_color = .{ .rgb = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b, }, }; },
.direct_color_bg => |rgb| { self.cursor.style.bg_color = .{ .rgb = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b, }, }; },
.@"8_fg" => |n| { self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; },
.@"8_bg" => |n| { self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; },
.reset_fg => self.cursor.style.fg_color = .none,
.reset_bg => self.cursor.style.bg_color = .none,
.@"8_bright_fg" => |n| { self.cursor.style.fg_color = .{ .palette = @intFromEnum(n) }; },
.@"8_bright_bg" => |n| { self.cursor.style.bg_color = .{ .palette = @intFromEnum(n) }; },
.@"256_fg" => |idx| { self.cursor.style.fg_color = .{ .palette = idx }; },
.@"256_bg" => |idx| { self.cursor.style.bg_color = .{ .palette = idx }; },
.unknown => return, }
try self.manualStyleUpdate();}
/// Call this whenever you manually change the cursor style.////// This function can NOT fail if the cursor style is changing to the/// default style.////// If this returns an error, the style change did not take effect and/// the cursor style is reverted back to the default. The only scenario/// this returns an error is if there is a physical memory allocation failure/// or if there is no possible way to increase style capacity to store/// the style.////// This function WILL split pages as necessary to accommodate the new style./// So if OutOfSpace is returned, it means that even after splitting the page/// there was still no room for the new style.pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { defer self.assertIntegrity(); var page: *Page = &self.cursor.page_pin.node.data;
// std.log.warn("active styles={}", .{page.styles.count()});
// Release our previous style if it was not default. if (self.cursor.style_id != style.default_id) { page.styles.release(page.memory, self.cursor.style_id); }
// If our new style is the default, just reset to that if (self.cursor.style.default()) { self.cursor.style_id = style.default_id; return; }
// Clear the cursor style ID to prevent weird things from happening // if the page capacity has to be adjusted which would end up calling // manualStyleUpdate again. // // This also ensures that if anything fails below, we fall back to // clearing our style. self.cursor.style_id = style.default_id;
// After setting the style, we need to update our style map. // Note that we COULD lazily do this in print. We should look into // if that makes a meaningful difference. Our priority is to keep print // fast because setting a ton of styles that do nothing is uncommon // and weird. const id = page.styles.add( page.memory, self.cursor.style, ) catch |err| id: { // Our style map is full or needs to be rehashed, so we need to // increase style capacity (or rehash). const node = self.increaseCapacity( self.cursor.page_pin.node, switch (err) { error.OutOfMemory => .styles, error.NeedsRehash => null, }, ) catch |increase_err| switch (increase_err) { error.OutOfMemory => return error.OutOfMemory, error.OutOfSpace => space: { // Out of space, we need to split the page. Split wherever // is using less capacity and hope that works. If it doesn't // work, we tried. try self.splitForCapacity(self.cursor.page_pin.*); break :space self.cursor.page_pin.node; }, };
page = &node.data; break :id page.styles.add( page.memory, self.cursor.style, ) catch |err2| switch (err2) { error.OutOfMemory => { // This shouldn't happen because increaseCapacity is // guaranteed to increase our capacity by at least one and // we only need one space, but again, I don't want to crash // here so let's log loudly and reset. log.err("style addition failed after capacity increase", .{}); return error.OutOfMemory; }, error.NeedsRehash => { // This should be impossible because we rehash above // and rehashing should never result in a duplicate. But // we don't want to simply hard crash so log it and // clear our style. log.err("style rehash resulted in needs rehash", .{}); return; }, }; }; errdefer page.styles.release(page.memory, id);
self.cursor.style_id = id;}
/// Split at the given pin so that the pinned row moves to the page/// with less used capacity after the split.////// The primary use case for this is to handle IncreaseCapacityError/// OutOfSpace conditions where we need to split the page in order/// to make room for more managed memory.////// If the caller cares about where the pin moves to, they should/// setup a tracked pin before calling this and then check that./// In many calling cases, the input pin is tracked (e.g. the cursor/// pin).////// If this returns OOM then its a system OOM. If this returns OutOfSpace/// then it means the page can't be split further.fn splitForCapacity( self: *Screen, pin: Pin,) PageList.SplitError!void { // Get our capacities. We include our target row because its // capacity will be preserved. const bytes_above = Page.layout(pin.node.data.exactRowCapacity( 0, pin.y + 1, )).total_size; const bytes_below = Page.layout(pin.node.data.exactRowCapacity( pin.y, pin.node.data.size.rows, )).total_size;
// We need to track the old cursor pin because if our split // moves the cursor pin we need to update our accounting. const old_cursor = self.cursor.page_pin.*;
// If our bytes above are less than bytes below, we move the pin // to split down one since splitting includes the pinned row in // the new node. try self.pages.split(if (bytes_above < bytes_below) pin.down(1) orelse pin else pin);
// Cursor didn't change nodes, we're done. if (self.cursor.page_pin.node == old_cursor.node) return;
// Cursor changed, we need to restore the old pin then use // cursorChangePin to move to the new pin. The old node is guaranteed // to still exist, just not the row. // // Note that page_row and all that will be invalid, it points to the // new node, but at the time of writing this we don't need any of that // to be right in cursorChangePin. const new_cursor = self.cursor.page_pin.*; self.cursor.page_pin.* = old_cursor; self.cursorChangePin(new_cursor);}
/// Append a grapheme to the given cell within the current cursor row.pub fn appendGrapheme( self: *Screen, cell: *Cell, cp: u21,) PageList.IncreaseCapacityError!void { defer self.cursor.page_pin.node.data.assertIntegrity(); self.cursor.page_pin.node.data.appendGrapheme( self.cursor.page_row, cell, cp, ) catch |err| switch (err) { error.OutOfMemory => { // We need to determine the actual cell index of the cell so // that after we adjust the capacity we can reload the cell. const cell_idx: usize = cell_idx: { const cells: [*]Cell = @ptrCast(self.cursor.page_cell); const zero: [*]Cell = cells - self.cursor.x; const target: [*]Cell = @ptrCast(cell); const cell_idx = (@intFromPtr(target) - @intFromPtr(zero)) / @sizeOf(Cell); break :cell_idx cell_idx; };
// Adjust our capacity. This will update our cursor page pin and // force us to reload. _ = try self.increaseCapacity( self.cursor.page_pin.node, .grapheme_bytes, );
// The cell pointer is now invalid, so we need to get it from // the reloaded cursor pointers. const reloaded_cell: *Cell = switch (std.math.order(cell_idx, self.cursor.x)) { .eq => self.cursor.page_cell, .lt => self.cursorCellLeft(@intCast(self.cursor.x - cell_idx)), .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), };
self.cursor.page_pin.node.data.appendGrapheme( self.cursor.page_row, reloaded_cell, cp, ) catch |err2| { comptime assert(@TypeOf(err2) == error{OutOfMemory}); // This should never happen because we just increased capacity. // Log loudly but still return an error so we don't just // crash. log.err("grapheme append failed after capacity increase", .{}); return err2; }; }, };}
/// Start the hyperlink state. Future cells will be marked as hyperlinks with/// this state. Note that various terminal operations may clear the hyperlink/// state, such as switching screens (alt screen).pub fn startHyperlink( self: *Screen, uri: []const u8, id_: ?[]const u8,) PageList.IncreaseCapacityError!void { // Create our pending entry. const link: hyperlink.Hyperlink = .{ .uri = uri, .id = if (id_) |id| .{ .explicit = id, } else implicit: { defer self.cursor.hyperlink_implicit_id += 1; break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id }; }, }; errdefer switch (link.id) { .explicit => {}, .implicit => self.cursor.hyperlink_implicit_id -= 1, };
// Loop until we have enough page memory to add the hyperlink while (true) { if (self.startHyperlinkOnce(link)) { return; } else |err| switch (err) { // An actual self.alloc OOM is a fatal error. error.OutOfMemory => return error.OutOfMemory,
// strings table is out of memory, adjust it up error.StringsOutOfMemory => _ = try self.increaseCapacity( self.cursor.page_pin.node, .string_bytes, ),
// hyperlink set is out of memory, adjust it up error.SetOutOfMemory => _ = try self.increaseCapacity( self.cursor.page_pin.node, .hyperlink_bytes, ),
// hyperlink set is too full, rehash it error.SetNeedsRehash => _ = try self.increaseCapacity( self.cursor.page_pin.node, null, ), }
self.assertIntegrity(); }}
/// This is like startHyperlink but if we have to adjust page capacities/// this returns error.PageAdjusted. This is useful so that we unwind/// all the previous state and try again.fn startHyperlinkOnce( self: *Screen, source: hyperlink.Hyperlink,) (Allocator.Error || Page.InsertHyperlinkError)!void { // End any prior hyperlink self.endHyperlink();
// Allocate our new Hyperlink entry in non-page memory. This // lets us quickly get access to URI, ID. const link = try self.alloc.create(hyperlink.Hyperlink); errdefer self.alloc.destroy(link); link.* = try source.dupe(self.alloc); errdefer link.deinit(self.alloc);
// Insert the hyperlink into page memory var page = &self.cursor.page_pin.node.data; const id: hyperlink.Id = try page.insertHyperlink(link.*);
// Save it all self.cursor.hyperlink = link; self.cursor.hyperlink_id = id;}
/// End the hyperlink state so that future cells aren't part of the/// current hyperlink (if any). This is safe to call multiple times.pub fn endHyperlink(self: *Screen) void { // If we have no hyperlink state then do nothing if (self.cursor.hyperlink_id == 0) { assert(self.cursor.hyperlink == null); return; }
// Release the old hyperlink state. If there are cells using the // hyperlink this will work because the creation creates a reference // and all additional cells create a new reference. This release will // just release our initial reference. // // If the ref count reaches zero the set will not delete the item // immediately; it is kept around in case it is used again (this is // how RefCountedSet works). This causes some memory fragmentation but // is fine because if it is ever pruned the context deleted callback // will be called. var page: *Page = &self.cursor.page_pin.node.data; page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink.?.deinit(self.alloc); self.alloc.destroy(self.cursor.hyperlink.?); self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null;}
/// Set the current hyperlink state on the current cell.pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { assert(self.cursor.hyperlink_id != 0);
var page = &self.cursor.page_pin.node.data; if (page.setHyperlink( self.cursor.page_row, self.cursor.page_cell, self.cursor.hyperlink_id, )) { // Success, increase the refcount for the hyperlink. page.hyperlink_set.use(page.memory, self.cursor.hyperlink_id); return; } else |err| switch (err) { // hyperlink_map is out of space, realloc the page to be larger error.HyperlinkMapOutOfMemory => { // Attempt to allocate the space that would be required to // insert a new copy of the cursor hyperlink uri in to the // string alloc, since right now increaseCapacity always just // adds an extra copy even if one already exists in the page. // If this alloc fails then we know we also need to grow our // string bytes. // // FIXME: increaseCapacity should not do this. while (self.cursor.hyperlink) |link| { if (page.string_alloc.alloc( u8, page.memory, link.uri.len, )) |slice| { // We don't bother freeing because we're // about to free the entire page anyway. _ = slice; break; } else |_| {}
// We didn't have enough room, let's increase string bytes const new_node = try self.increaseCapacity( self.cursor.page_pin.node, .string_bytes, ); assert(new_node == self.cursor.page_pin.node); page = &new_node.data; }
_ = try self.increaseCapacity( self.cursor.page_pin.node, .hyperlink_bytes, );
// Retry // // We check that the cursor hyperlink hasn't been destroyed // by the capacity adjustment first though- since despite the // terrible code above, that can still apparently happen ._. if (self.cursor.hyperlink_id > 0) { return try self.cursorSetHyperlink(); } }, }}
/// Modify the semantic content type of the cursor. This should/// be preferred over setting it manually since it handles all the/// proper accounting.pub fn cursorSetSemanticContent(self: *Screen, t: union(enum) { prompt: osc.semantic_prompt.PromptKind, output, input: enum { clear_explicit, clear_eol },}) void { const cursor = &self.cursor;
switch (t) { .output => { cursor.semantic_content = .output; cursor.semantic_content_clear_eol = false; },
.input => |clear| { cursor.semantic_content = .input; cursor.semantic_content_clear_eol = switch (clear) { .clear_explicit => false, .clear_eol => true, }; },
.prompt => |kind| { self.semantic_prompt.seen = true; cursor.semantic_content = .prompt; cursor.semantic_content_clear_eol = false; cursor.page_row.semantic_prompt = switch (kind) { .initial, .right => .prompt, .continuation, .secondary => .prompt_continuation, }; }, }}
/// Set the selection to the given selection. If this is a tracked selection/// then the screen will take ownership of the selection. If this is untracked/// then the screen will convert it to tracked internally. This will automatically/// untrack the prior selection (if any).////// Set the selection to null to clear any previous selection.////// This is always recommended over setting `selection` directly. Beyond/// managing memory for you, it also performs safety checks that the selection/// is always tracked.pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void { const sel = sel_ orelse { self.clearSelection(); return; };
// If this selection is untracked then we track it. const tracked_sel = if (sel.tracked()) sel else try sel.track(self); errdefer if (!sel.tracked()) tracked_sel.deinit(self);
// Untrack prior selection if (self.selection) |*old| old.deinit(self); self.selection = tracked_sel; self.dirty.selection = true;}
/// Same as select(null) but can't fail.pub fn clearSelection(self: *Screen) void { if (self.selection) |*sel| { sel.deinit(self); self.dirty.selection = true; } self.selection = null;}
pub const SelectionString = struct { /// The selection to convert to a string. sel: Selection,
/// If true, trim whitespace around the selection. trim: bool = true,
/// If non-null, a stringmap will be written here. This will use /// the same allocator as the call to selectionString. The string will /// be duplicated here and in the return value so both must be freed. map: ?*StringMap = null,};
const selectionString_tw = tripwire.module(enum { copy_map,}, selectionString);
/// Returns the raw text associated with a selection. This will unwrap/// soft-wrapped edges. The returned slice is owned by the caller and allocated/// using alloc, not the allocator associated with the screen (unless they match).////// For more flexibility, use a ScreenFormatter directly.pub fn selectionString( self: *Screen, alloc: Allocator, opts: SelectionString,) Allocator.Error![:0]const u8 { // We'll use this as our buffer to build our string. var aw: std.Io.Writer.Allocating = .init(alloc); defer aw.deinit();
// Create a formatter and use that to emit our text. var formatter: ScreenFormatter = .init( self, .{ .emit = .plain, .unwrap = true, .trim = opts.trim, }, ); formatter.content = .{ .selection = opts.sel };
// If we have a string map, we need to set that up. var pins: std.ArrayList(Pin) = .empty; defer pins.deinit(alloc); if (opts.map != null) formatter.pin_map = .{ .alloc = alloc, .map = &pins, };
// Emit. Since this is an allocating writer, a failed write // just becomes an OOM. formatter.format(&aw.writer) catch return error.OutOfMemory;
// Build our final text and if we have a string map set that up. const text = try aw.toOwnedSliceSentinel(0); errdefer alloc.free(text); if (opts.map) |map| { const map_string = try alloc.dupeZ(u8, text); errdefer alloc.free(map_string); try selectionString_tw.check(.copy_map); const map_pins = try pins.toOwnedSlice(alloc); map.* = .{ .string = map_string, .map = map_pins, }; }
return text;}
pub const SelectLine = struct { /// The pin of some part of the line to select. pin: Pin,
/// These are the codepoints to consider whitespace to trim /// from the ends of the selection. whitespace: ?[]const u21 = &selection_codepoints.default_line_whitespace,
/// If true, line selection will consider semantic prompt /// state changing a boundary. State changing is ANY state /// change. semantic_prompt_boundary: bool = true,};
/// Select the line under the given point. This will select across soft-wrapped/// lines and will omit the leading and trailing whitespace. If the point is/// over whitespace but the line has non-whitespace characters elsewhere, the/// line will be selected.pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { _ = self;
// Get the current point semantic prompt state since that determines // boundary conditions too. This makes it so that line selection can // only happen within the same prompt state. For example, if you triple // click output, but the shell uses spaces to soft-wrap to the prompt // then the selection will stop prior to the prompt. See issue #1329. const semantic_prompt_state: ?Cell.SemanticContent = state: { if (!opts.semantic_prompt_boundary) break :state null; const rac = opts.pin.rowAndCell(); break :state rac.cell.semantic_content; };
// The real start of the row is the first row in the soft-wrap. const start_pin: Pin = start_pin: { var it = opts.pin.rowIterator(.left_up, null); var it_prev: Pin = it.next().?; // skip self
// First, check the current row for semantic boundaries before the clicked position. if (semantic_prompt_state) |v| { const row = it_prev.rowAndCell().row; const cells = it_prev.node.data.getCells(row); // Scan backwards from clicked position to find where our content starts for (0..opts.pin.x + 1) |i| { const x_rev = opts.pin.x - i; if (cells[x_rev].semantic_content != v) { var copy = it_prev; copy.x = @intCast(x_rev + 1); break :start_pin copy; } }
// No boundary found before clicked position on current row. // If row doesn't wrap from above, start is at column 0. // Otherwise, continue checking previous rows. }
while (it.next()) |p| { const row = p.rowAndCell().row;
if (!row.wrap) { var copy = it_prev; copy.x = 0; break :start_pin copy; }
if (semantic_prompt_state) |v| { // We need to check every cell in this row in reverse // order since we're going up and back. const cells = p.node.data.getCells(row); for (0..cells.len) |x| { const x_rev = cells.len - 1 - x; const cell = cells[x_rev]; if (cell.semantic_content != v) break :start_pin it_prev; it_prev = p; it_prev.x = @intCast(x_rev); }
continue; }
it_prev = p; } else { var copy = it_prev; copy.x = 0; break :start_pin copy; } };
// The real end of the row is the final row in the soft-wrap. const end_pin: Pin = end_pin: { var it = opts.pin.rowIterator(.right_down, null); while (it.next()) |p| { const row = p.rowAndCell().row;
if (semantic_prompt_state) |v| { // We need to check every cell in this row const cells = p.node.data.getCells(row);
// If this is our pin row we can start from our x because // the start_pin logic already found the real start. const start_offset = if (p.node == opts.pin.node and p.y == opts.pin.y) opts.pin.x else 0;
// Handle the zero case specially because if the first // col doesn't match then we end at the end of the prior // row. But if this is the first row, we can't go back, // so we scan forward to find where our content ends. if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; prev.x = p.node.data.size.cols - 1; break :end_pin prev; }
// For every other case, we end at the prior cell. for (start_offset.., cells[start_offset..]) |x, cell| { if (cell.semantic_content != v) { var copy = p; copy.x = @intCast(x - 1); break :end_pin copy; } } }
if (!row.wrap) { var copy = p; copy.x = p.node.data.size.cols - 1; break :end_pin copy; } }
return null; };
// Go forward from the start to find the first non-whitespace character. const start: Pin = start: { const whitespace = opts.whitespace orelse break :start start_pin; var it = start_pin.cellIterator(.right_down, end_pin); while (it.next()) |p| { const cell = p.rowAndCell().cell; if (!cell.hasText()) continue;
// Non-empty means we found it. const this_whitespace = std.mem.indexOfScalar( u21, whitespace, cell.content.codepoint, ) != null; if (this_whitespace) continue;
break :start p; }
return null; };
// Go backward from the end to find the first non-whitespace character. const end: Pin = end: { const whitespace = opts.whitespace orelse break :end end_pin; var it = end_pin.cellIterator(.left_up, start_pin); while (it.next()) |p| { const cell = p.rowAndCell().cell; if (!cell.hasText()) continue;
// Non-empty means we found it. const this_whitespace = std.mem.indexOfScalar( u21, whitespace, cell.content.codepoint, ) != null; if (this_whitespace) continue;
break :end p; }
return null; };
return .init(start, end, false);}
/// Return the selection for all contents on the screen. Surrounding/// whitespace is omitted. If there is no selection, this returns null.pub fn selectAll(self: *Screen) ?Selection { const whitespace = &[_]u32{ 0, ' ', '\t' };
const start: Pin = start: { var it = self.pages.cellIterator( .right_down, .{ .screen = .{} }, null, ); while (it.next()) |p| { const cell = p.rowAndCell().cell; if (!cell.hasText()) continue;
// Non-empty means we found it. const this_whitespace = std.mem.indexOfAny( u32, whitespace, &[_]u32{cell.content.codepoint}, ) != null; if (this_whitespace) continue;
break :start p; }
return null; };
const end: Pin = end: { var it = self.pages.cellIterator( .left_up, .{ .screen = .{} }, null, ); while (it.next()) |p| { const cell = p.rowAndCell().cell; if (!cell.hasText()) continue;
// Non-empty means we found it. const this_whitespace = std.mem.indexOfAny( u32, whitespace, &[_]u32{cell.content.codepoint}, ) != null; if (this_whitespace) continue;
break :end p; }
return null; };
return .init(start, end, false);}
/// Select the nearest word to start point that is between start_pt and/// end_pt (inclusive). Because it selects "nearest" to start point, start/// point can be before or after end point.////// The boundary_codepoints parameter should be a slice of u21 codepoints that/// mark word boundaries, passed through to selectWord.////// TODO: test thispub fn selectWordBetween( self: *Screen, start: Pin, end: Pin, boundary_codepoints: []const u21,) ?Selection { const dir: PageList.Direction = if (start.before(end)) .right_down else .left_up; var it = start.cellIterator(dir, end); while (it.next()) |pin| { // Boundary conditions switch (dir) { .right_down => if (end.before(pin)) return null, .left_up => if (pin.before(end)) return null, }
// If we found a word, then return it if (self.selectWord(pin, boundary_codepoints)) |sel| return sel; }
return null;}
/// Select the word under the given point. A word is any consecutive series/// of characters that are exclusively whitespace or exclusively non-whitespace./// A selection can span multiple physical lines if they are soft-wrapped.////// This will return null if a selection is impossible. The only scenario/// this happens is if the point pt is outside of the written screen space.////// The boundary_codepoints parameter should be a slice of u21 codepoints that/// mark word boundaries. This is expected to be pre-parsed from the config.pub fn selectWord( self: *Screen, pin: Pin, boundary_codepoints: []const u21,) ?Selection { _ = self;
// If our cell is empty we can't select a word, because we can't select // areas where the screen is not yet written. const start_cell = pin.rowAndCell().cell; if (!start_cell.hasText()) return null;
// Determine if we are a boundary or not to determine what our boundary is. const expect_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, start_cell.content.codepoint, ) != null;
// Go forwards to find our end boundary const end: Pin = end: { var it = pin.cellIterator(.right_down, null); var prev = it.next().?; // Consume one, our start while (it.next()) |p| { const rac = p.rowAndCell(); const cell = rac.cell;
// If we reached an empty cell its always a boundary if (!cell.hasText()) break :end prev;
// If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :end prev;
// If we are going to the next row and it isn't wrapped, we // return the previous. if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { break :end p; }
prev = p; }
break :end prev; };
// Go backwards to find our start boundary const start: Pin = start: { var it = pin.cellIterator(.left_up, null); var prev = it.next().?; // Consume one, our start while (it.next()) |p| { const rac = p.rowAndCell(); const cell = rac.cell;
// If we are going to the next row and it isn't wrapped, we // return the previous. if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { break :start prev; }
// If we reached an empty cell its always a boundary if (!cell.hasText()) break :start prev;
// If we do not match our expected set, we hit a boundary const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, cell.content.codepoint, ) != null; if (this_boundary != expect_boundary) break :start prev;
prev = p; }
break :start prev; };
return .init(start, end, false);}
/// Select the command output under the given point. The limits of the output/// are determined by semantic prompt information provided by shell integration./// A selection can span multiple physical lines if they are soft-wrapped.////// This will return null if a selection is impossible:/// - the point pt is outside of the written screen space./// - the point pt is on a prompt / input line.pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { // If our pin right now is not on output, then we return nothing. if (pin.rowAndCell().cell.semantic_content != .output) return null;
// Get the post prior prompt from this pin. This is the prompt whose // output we'll be capturing. const prompt_pin: Pin = prompt: { // If we have a prompt above this point (including this point), // then thats the prompt we want to capture output from. var it = pin.promptIterator(.left_up, null); if (it.next()) |p| break :prompt p;
// If we don't have a prompt, then we assume that we're // capturing all the output up to the next prompt. it = pin.promptIterator(.right_down, null); const next = it.next() orelse return null;
// We'll capture from the start of the screen to just above // the prompt and will trim the trailing whitespace. const start_pin = self.pages.getTopLeft(.screen); var end_pin = next.up(1) orelse return null; end_pin.x = end_pin.node.data.size.cols - 1; var cell_it = end_pin.cellIterator(.left_up, start_pin); while (cell_it.next()) |p| { const cell = p.rowAndCell().cell; end_pin = p; if (cell.hasText()) break; }
return .init( start_pin, end_pin, false, ); };
// Grab our content var hl = self.pages.highlightSemanticContent( prompt_pin, .output, ) orelse return null;
// Trim our trailing whitespace var cell_it = hl.end.cellIterator(.left_up, hl.start); while (cell_it.next()) |p| { const cell = p.rowAndCell().cell; hl.end = p; if (cell.hasText()) break; }
return .init(hl.start, hl.end, false);}
pub const LineIterator = struct { screen: *const Screen, current: ?Pin = null,
pub fn next(self: *LineIterator) ?Selection { const current = self.current orelse return null; const result = self.screen.selectLine(.{ .pin = current, .whitespace = null, .semantic_prompt_boundary = false, }) orelse { self.current = null; return null; };
self.current = result.end().down(1); return result; }};
/// Returns an iterator to move through the soft-wrapped lines starting/// from pin.pub fn lineIterator(self: *const Screen, start: Pin) LineIterator { return LineIterator{ .screen = self, .current = start, };}
pub const PromptClickMove = struct { left: usize, right: usize,
pub const zero = PromptClickMove{ .left = 0, .right = 0, };};
/// Determine the inputs necessary to move the cursor to the given/// click location within a prompt input area.////// If the cursor isn't currently at a prompt input location, this/// returns no movement.////// This feature depends on well-behaved OSC133 shell integration. Specifically,/// this only moves over designated input areas (OSC 133 B). It is assumed/// that the shell will only move the cursor to input cells, so prompt cells/// and other blank cells are ignored as part of the movement calculation.pub fn promptClickMove( self: *Screen, click_pin: Pin,) PromptClickMove { // If we're not at an input cell with our cursor, no movement will // ever be possible. if (self.cursor.semantic_content != .input and self.cursor.page_cell.semantic_content != .input) return .zero;
return switch (self.semantic_prompt.click) { // None doesn't support movement and click_events must use a // different mechanism (SGR mouse events) that callers must handle. .none, .click_events => .zero, .cl => |cl| switch (cl) { // All of these currently use dumb line-based navigation. // But eventually we'll support more. .line, .multiple, .conservative_vertical, .smart_vertical, => self.promptClickLine(click_pin), }, };}
/// Determine the inputs required to move from the cursor to the given/// click location. If the cursor isn't currently at a prompt input/// location, this will return zero.////// This currently only supports moving a single line.fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { // If our click pin is our cursor pin, no movement is needed. // Do this early so we can assume later that they are different. const cursor_pin = self.cursor.page_pin.*; if (cursor_pin.eql(click_pin)) return .zero;
// If our cursor is before our click, we're only emitting right inputs. if (cursor_pin.before(click_pin)) { var count: usize = 0;
// We go row-by-row because soft-wrapped rows are still a single // line to a shell, so we can't just look at our page row. var row_it = cursor_pin.rowIterator( .right_down, click_pin, ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); const cells = row_pin.node.data.getCells(rac.row);
// Determine if this row is our cursor. const is_cursor_row = row_pin.node == cursor_pin.node and row_pin.y == cursor_pin.y;
// If this is not the cursor row, verify it's still part of the // continuation of our starting prompt. if (!is_cursor_row and rac.row.semantic_prompt != .prompt_continuation) break;
// Determine where our input starts. const start_x: usize = start_x: { // If this is our cursor row then we start after the cursor. if (is_cursor_row) break :start_x cursor_pin.x + 1;
// Otherwise, we start at the first input cell, because // we expect the shell to properly translate arrows across // lines to the start of the input. Some shells indent // where input starts on subsequent lines so we must do // this. for (cells, 0..) |cell, x| { if (cell.semantic_content == .input) break :start_x x; }
// We never found an input cell, so we need to move to the // next row. break :start_x cells.len; };
// Iterate over the input cells and assume arrow keys only // jump to input cells. for (cells[start_x..], start_x..) |cell, x| { // Ignore non-input cells, but allow breaks. We assume // the shell will translate arrow keys to only input // areas. if (cell.semantic_content != .input) continue;
// Increment our input count count += 1;
// If this is our target, we're done. if (row_pin.node == click_pin.node and row_pin.y == click_pin.y and x == click_pin.x) break :row_it; }
// If this row isn't soft-wrapped, we need to break out // because line based moving only handles single lines. // We're done! if (!rac.row.wrap) { // If we never found our pin, that means we clicked further // right/beyond it. If we're already on a non-empty input cell // then we add one so we can move to the newest, empty cell // at the end, matching typical editor behavior. if (self.cursor.page_cell.semantic_content == .input) count += 1;
break; } }
return .{ .left = 0, .right = count }; }
// Otherwise, cursor is after click, so we're emitting left inputs. var count: usize = 0;
// We go row-by-row because soft-wrapped rows are still a single // line to a shell, so we can't just look at our page row. var row_it = cursor_pin.rowIterator( .left_up, click_pin, ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); const cells = row_pin.node.data.getCells(rac.row);
// Determine the length of the cells we look at in this row. const end_len: usize = end_len: { // If this is our cursor row then we end before the cursor. if (row_pin.node == cursor_pin.node and row_pin.y == cursor_pin.y) break :end_len cursor_pin.x;
// Otherwise, we end at the last cell in the row. break :end_len cells.len; };
// Iterate backwards over the input cells. for (0..end_len) |rev_x| { const x: usize = end_len - 1 - rev_x; const cell = cells[x];
// Ignore non-input cells. if (cell.semantic_content != .input) continue;
// Increment our input count count += 1;
// If this is our target, we're done. if (row_pin.node == click_pin.node and row_pin.y == click_pin.y and x == click_pin.x) break :row_it; }
// If this row is not a wrap continuation, then break out if (!rac.row.wrap_continuation) break; }
return .{ .left = count, .right = 0 };}
/// Dump the screen to a string. The writer given should be buffered;/// this function does not attempt to efficiently write and generally writes/// one byte at a time.pub fn dumpString( self: *const Screen, writer: *std.Io.Writer, opts: struct { /// The start and end points of the dump, both inclusive. The x will /// be ignored and the full row will always be dumped. tl: Pin, br: ?Pin = null,
/// If true, this will unwrap soft-wrapped lines. If false, this will /// dump the screen as it is visually seen in a rendered window. unwrap: bool = true, },) std.Io.Writer.Error!void { // Create a formatter and use that to emit our text. var formatter: ScreenFormatter = .init(self, .{ .emit = .plain, .unwrap = opts.unwrap, .trim = false, });
// Set up the selection based on the pins const tl = opts.tl; const br = opts.br orelse self.pages.getBottomRight(.screen).?;
formatter.content = .{ .selection = Selection.init( tl, br, false, // not rectangle ), };
// Emit try formatter.format(writer);}
/// You should use dumpString, this is a restricted version mostly for/// legacy and convenience reasons for unit tests.pub fn dumpStringAlloc( self: *const Screen, alloc: Allocator, tl: point.Point,) ![]const u8 { var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit();
try self.dumpString(&builder.writer, .{ .tl = self.pages.getTopLeft(tl), .br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint, .unwrap = false, });
return try builder.toOwnedSlice();}
/// You should use dumpString, this is a restricted version mostly for/// legacy and convenience reasons for unit tests.pub fn dumpStringAllocUnwrapped( self: *const Screen, alloc: Allocator, tl: point.Point,) ![]const u8 { var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit();
try self.dumpString(&builder.writer, .{ .tl = self.pages.getTopLeft(tl), .br = self.pages.getBottomRight(tl) orelse return error.UnknownPoint, .unwrap = true, });
return try builder.toOwnedSlice();}
/// This is basically a really jank version of Terminal.printString. We/// have to reimplement it here because we want a way to print to the screen/// to test it but don't want all the features of Terminal.pub fn testWriteString(self: *Screen, text: []const u8) !void { const view = try std.unicode.Utf8View.init(text); var iter = view.iterator(); while (iter.nextCodepoint()) |c| { // Explicit newline forces a new row if (c == '\n') { try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.pending_wrap = false; if (self.cursor.semantic_content_clear_eol) { self.cursorSetSemanticContent(.output); } else switch (self.cursor.semantic_content) { .output => {}, .prompt, .input => self.cursor.page_row.semantic_prompt = .prompt_continuation, } continue; }
const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); if (width == 0) { const cell = cell: { var cell = self.cursorCellLeft(1); switch (cell.wide) { .narrow => {}, .wide => {}, .spacer_head => unreachable, .spacer_tail => cell = self.cursorCellLeft(2), }
break :cell cell; };
try self.cursor.page_pin.node.data.appendGrapheme( self.cursor.page_row, cell, c, ); continue; }
if (self.cursor.pending_wrap) { assert(self.cursor.x == self.pages.cols - 1); self.cursor.pending_wrap = false; self.cursor.page_row.wrap = true; try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.page_row.wrap_continuation = true; switch (self.cursor.semantic_content) { .output => {}, .input, .prompt => self.cursor.page_row.semantic_prompt = .prompt_continuation, } }
assert(width == 1 or width == 2); switch (width) { 1 => { self.cursor.page_cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = c }, .style_id = self.cursor.style_id, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, };
// If we have a ref-counted style, increase. if (self.cursor.style_id != style.default_id) { const page = self.cursor.page_pin.node.data; page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; }
// If we have a hyperlink, add it to the cell. if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink(); },
2 => { // Need a wide spacer head if (self.cursor.x == self.pages.cols - 1) { self.cursor.page_cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 0 }, .wide = .spacer_head, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, };
// If we have a hyperlink, add it to the cell. if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
self.cursor.page_row.wrap = true; try self.cursorDownOrScroll(); self.cursorHorizontalAbsolute(0); self.cursor.page_row.wrap_continuation = true; }
// Write our wide char self.cursor.page_cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = c }, .style_id = self.cursor.style_id, .wide = .wide, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, };
// If we have a hyperlink, add it to the cell. if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
// Write our tail self.cursorRight(1); self.cursor.page_cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 0 }, .wide = .spacer_tail, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, };
// If we have a hyperlink, add it to the cell. if (self.cursor.hyperlink_id > 0) try self.cursorSetHyperlink();
// If we have a ref-counted style, increase twice. if (self.cursor.style_id != style.default_id) { const page = self.cursor.page_pin.node.data; page.styles.use(page.memory, self.cursor.style_id); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } },
else => unreachable, }
if (self.cursor.x + 1 < self.pages.cols) { self.cursorRight(1); } else { self.cursor.pending_wrap = true; } }}
test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id);
try s.testWriteString("hello, world"); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("hello, world", str);}
test "Screen read and write newline" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id);
try s.testWriteString("hello\nworld"); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("hello\nworld", str);}
test "Screen read and write scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("hello\nworld\ntest"); { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("hello\nworld\ntest", str); } { const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("world\ntest", str); }}
test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit();
try s.testWriteString("hello\nworld\ntest"); { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("world\ntest", str); } { const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("world\ntest", str); }}
test "Screen read and write no scrollback large" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); defer s.deinit();
for (0..1_000) |i| { var buf: [128]u8 = undefined; const str = try std.fmt.bufPrint(&buf, "{}\n", .{i}); try s.testWriteString(str); } try s.testWriteString("1000");
{ const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("999\n1000", str); }}
test "Screen cursorCopy x/y" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); s.cursorAbsolute(2, 3); try testing.expect(s.cursor.x == 2); try testing.expect(s.cursor.y == 3);
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.x == 2); try testing.expect(s2.cursor.y == 3); try s2.testWriteString("Hello");
{ const str = try s2.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("\n\n\n Hello", str); }}
test "Screen cursorCopy style deref" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data;
// Bold should create our style try s2.setAttribute(.{ .bold = {} }); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(s2.cursor.style.flags.bold);
// Copy default style, should release our style try s2.cursorCopy(s.cursor, .{}); try testing.expect(!s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 0), page.styles.count());}
test "Screen cursorCopy style deref new page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit();
// We need to get the cursor on a new page. const first_page_size = s2.pages.pages.first.?.data.capacity.rows;
// Fill the scrollback with blank lines until // there are only 5 rows left on the first page. s2.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
// s2.pages.diagram(...): // // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4300 |1 | | 0 // 4301 |2 | | 1 // 4302 |3 | | 2 // 4303 |4 | | 3 // 4304 |5 | | 4 // +----------+ : // +----------+ : = PAGE 1 // 0 |6 | | 5 // 1 |7 | | 6 // 2 |8 | | 7 // 3 |9 | | 8 // 4 |10 | | 9 // : ^ : : = PIN 0 // +----------+ : // +-------------+
// This should be PAGE 1 const page = &s2.cursor.page_pin.node.data;
// It should be the last page in the list. try testing.expectEqual(&s2.pages.pages.last.?.data, page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null);
// The cursor should be at 2, 9 try testing.expect(s2.cursor.x == 2); try testing.expect(s2.cursor.y == 9);
// Bold should create our style in page 1. try s2.setAttribute(.{ .bold = {} }); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(s2.cursor.style.flags.bold);
// Copy the cursor for the first screen. This should release // the style from page 1 and move the cursor back to page 0. try s2.cursorCopy(s.cursor, .{}); try testing.expect(!s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 0), page.styles.count()); // The page after the page the cursor is now in should be page 1. try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0);}
test "Screen cursorCopy style copy" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} });
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data; try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 1), page.styles.count());}
test "Screen cursorCopy hyperlink deref" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data;
// Create a hyperlink for the cursor. try s2.startHyperlink("https://example.com/", null); try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id != 0);
// Copy a cursor with no hyperlink, should release our hyperlink. try s2.cursorCopy(s.cursor, .{}); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0);}
test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); defer s2.deinit();
// We need to get the cursor on a new page. const first_page_size = s2.pages.pages.first.?.data.capacity.rows;
// Fill the scrollback with blank lines until // there are only 5 rows left on the first page. s2.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } s2.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10");
// s2.pages.diagram(...): // // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4300 |1 | | 0 // 4301 |2 | | 1 // 4302 |3 | | 2 // 4303 |4 | | 3 // 4304 |5 | | 4 // +----------+ : // +----------+ : = PAGE 1 // 0 |6 | | 5 // 1 |7 | | 6 // 2 |8 | | 7 // 3 |9 | | 8 // 4 |10 | | 9 // : ^ : : = PIN 0 // +----------+ : // +-------------+
// This should be PAGE 1 const page = &s2.cursor.page_pin.node.data;
// It should be the last page in the list. try testing.expectEqual(&s2.pages.pages.last.?.data, page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null);
// The cursor should be at 2, 9 try testing.expect(s2.cursor.x == 2); try testing.expect(s2.cursor.y == 9);
// Create a hyperlink for the cursor, should be in page 1. try s2.startHyperlink("https://example.com/", null); try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id != 0);
// Copy the cursor for the first screen. This should release // the hyperlink from page 1 and move the cursor back to page 0. try s2.cursorCopy(s.cursor, .{}); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); // The page after the page the cursor is now in should be page 1. try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0);}
test "Screen cursorCopy hyperlink copy" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
// Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink. try s2.cursorCopy(s.cursor, .{}); try testing.expectEqual(@as(usize, 1), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id != 0);}
test "Screen cursorCopy hyperlink copy disabled" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
// Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0);
var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); const page = &s2.cursor.page_pin.node.data;
try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink. try s2.cursorCopy(s.cursor, .{ .hyperlink = false }); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0);}
test "Screen style basics" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style try s.setAttribute(.{ .bold = {} }); try testing.expect(s.cursor.style_id != 0); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(s.cursor.style.flags.bold);
// Set another style, we should still only have one since it was unused try s.setAttribute(.{ .italic = {} }); try testing.expect(s.cursor.style_id != 0); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(s.cursor.style.flags.italic);}
test "Screen style reset to default" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style try s.setAttribute(.{ .bold = {} }); try testing.expect(s.cursor.style_id != 0); try testing.expectEqual(@as(usize, 1), page.styles.count());
// Reset to default try s.setAttribute(.{ .reset_bold = {} }); try testing.expect(s.cursor.style_id == 0); try testing.expectEqual(@as(usize, 0), page.styles.count());}
test "Screen style reset with unset" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 0), page.styles.count());
// Set a new style try s.setAttribute(.{ .bold = {} }); try testing.expect(s.cursor.style_id != 0); try testing.expectEqual(@as(usize, 1), page.styles.count());
// Reset to default try s.setAttribute(.{ .unset = {} }); try testing.expect(s.cursor.style_id == 0); try testing.expectEqual(@as(usize, 0), page.styles.count());}
test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("hello, world"); s.clearRows(.{ .active = .{} }, null, false); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str);}
test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("hello\nworld"); s.clearRows(.{ .active = .{} }, null, false); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str);}
test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit();
try s.setAttribute(.{ .bold = {} }); try s.testWriteString("hello world"); try s.setAttribute(.{ .unset = {} });
// We should have one style const page = &s.cursor.page_pin.node.data; try testing.expectEqual(@as(usize, 1), page.styles.count());
s.clearRows(.{ .active = .{} }, null, false);
// We should have none because active cleared it try testing.expectEqual(@as(usize, 0), page.styles.count());
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("", str);}
test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("UNPROTECTED"); s.cursor.protected = true; try s.testWriteString("PROTECTED"); s.cursor.protected = false; try s.testWriteString("UNPROTECTED"); try s.testWriteString("\n"); s.cursor.protected = true; try s.testWriteString("PROTECTED"); s.cursor.protected = false; try s.testWriteString("UNPROTECTED"); s.cursor.protected = true; try s.testWriteString("PROTECTED"); s.cursor.protected = false;
s.clearRows(.{ .active = .{} }, null, true);
const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings(" PROTECTED\nPROTECTED PROTECTED", str);}
test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("1\n2\n3\n4\n5\n6");
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("1\n2\n3\n4\n5\n6", str); }
s.eraseHistory(null);
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); }}
test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); defer s.deinit();
try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6");
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("A\nB\nC\n1\n2\n3\n4\n5\n6", str); }
s.eraseHistory(null);
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); } { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("2\n3\n4\n5\n6", str); }}
test "Screen eraseRows active partial" { const testing = std.testing; const alloc = testing.allocator;
var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
try s.testWriteString("1\n2\n3");
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("1\n2\n3", str); }
s.eraseActive(1);
{ const str = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(str); try testing.expectEqualStrings("3", str); } { const str = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(str); try testing.expectEqualStrings("3", str); }}
test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
// We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page != page); }
// Scroll back to the previous page s.cursorUp(1); { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page == page); }
// Go back up, set a style try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.node.data; const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }
// Go back down into the next page and we should have that style s.cursorDown(1); { const page = &s.cursor.page_pin.node.data; const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }}
test "Screen: cursorUp across pages preserves style" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
// We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page != page); }
// Go back up, set a style try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.node.data; const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }
// Go back down into the prev page and we should have that style s.cursorUp(1); { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page == page);
const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }}
test "Screen: cursorAbsolute across pages preserves style" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
// We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page != page); }
// Go back up, set a style try s.setAttribute(.{ .bold = {} }); { const page = &s.cursor.page_pin.node.data; const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }
// Go back down into the prev page and we should have that style s.cursorAbsolute(1, 1); { const page = &s.cursor.page_pin.node.data; try testing.expect(start_page == page);
const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }}
test "Screen: cursorAbsolute to page with insufficient capacity" { // This test checks for a very specific edge case // which previously resulted in memory corruption. // // The conditions for this edge case are as such: // - The cursor has an associated style or other managed memory. // - The cursor moves to a different page. // - The new page is at capacity and must have its capacity adjusted.
const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Scroll down enough to go to another page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
const new_page = &s.cursor.page_pin.node.data;
// We need our page to change for this test to make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. try testing.expect(start_page != new_page);
// Add styles to the start page until it reaches capacity. { // Pause integrity checks because they're slow and // we're not testing this, this is just setup. start_page.pauseIntegrityChecks(true); defer start_page.pauseIntegrityChecks(false); defer start_page.assertIntegrity();
var n: u24 = 1; while (start_page.styles.add( start_page.memory, .{ .bg_color = .{ .rgb = @bitCast(n) } }, )) |_| n += 1 else |_| {} }
// Set a style on the cursor. try s.setAttribute(.{ .bold = {} }); { const styleval = new_page.styles.get( new_page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }
// Go back up into the start page and we should still have that style. s.cursorAbsolute(1, 1); { const cur_page = &s.cursor.page_pin.node.data; // The page we're on now should NOT equal start_page, since its // capacity should have been adjusted, which invalidates our ptr. try testing.expect(start_page != cur_page); // To make sure we DID change pages we check we're not on new_page. try testing.expect(new_page != cur_page);
const styleval = cur_page.styles.get( cur_page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold); }
s.cursor.page_pin.node.data.assertIntegrity(); new_page.assertIntegrity();}
test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scroll down, should still be bottom try s.cursorDownScroll(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Everything is dirty because we have no scrollback try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
// Scrolling to the bottom does nothing s.scroll(.{ .active = {} });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }}
test "Screen: scrolling with a single-row screen no scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD");
// Scroll down, should still be bottom try s.cursorDownScroll(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }
// Screen should be dirty try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));}
test "Screen: scrolling with a single-row screen with scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD");
// Scroll down, should still be bottom try s.cursorDownScroll(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }
// Active should be dirty try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
// Scrollback also dirty because cursor moved from there try testing.expect(s.pages.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
s.scroll(.{ .delta_row = -1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); }}
test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); const start_page = &s.pages.pages.last.?.data;
// Scroll down enough to go to another page const rem = start_page.capacity.rows - start_page.size.rows + 1; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
// We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. const page = &s.pages.pages.last.?.data; try testing.expect(start_page != page);
const styleval = page.styles.get( page.memory, s.cursor.style_id, ); try testing.expect(styleval.flags.bold);}
test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Scrolling up does nothing, but allows it s.scroll(.{ .delta_row = -1 }); try testing.expect(s.pages.viewport == .active);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }}
test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.cursorDownScroll();
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scrolling to the bottom s.scroll(.{ .active = {} }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scrolling back should make it visible again s.scroll(.{ .delta_row = -1 }); try testing.expect(s.pages.viewport != .active); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
// Scrolling back again should do nothing s.scroll(.{ .delta_row = -1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
// Scrolling to the bottom s.scroll(.{ .active = {} }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scrolling forward with no grow should do nothing s.scroll(.{ .delta_row = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scrolling to the top should work s.scroll(.{ .top = {} }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
// Should be able to easily clear active area only s.clearRows(.{ .active = .{} }, null, false); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); }
// Scrolling to the bottom s.scroll(.{ .active = {} }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }}
test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL");
// Scroll to top s.scroll(.{ .top = {} }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
// Scroll down multiple s.scroll(.{ .delta_row = 5 }); try testing.expect(s.pages.viewport == .active); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); }}
test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 50 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }}
test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH");
// First test: we scroll up by 1, so we're not at the bottom anymore. s.scroll(.{ .delta_row = -1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); }
// Next, we scroll back down by 1, this grows the scrollback but we // shouldn't move. try s.cursorDownScroll(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); }
// Scroll again, this clears scrollback so we should move viewports // but still see the same thing since our original view fits. try s.cursorDownScroll(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n4ABCD", contents); }}
test "Screen: scrolling moves selection" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, false, ));
// Scroll down, should still be bottom try s.cursorDownScroll();
// Our selection should've moved up { const sel = s.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s.pages.cols - 1, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
{ // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scrolling to the bottom does nothing s.scroll(.{ .active = {} });
// Our selection should've stayed the same { const sel = s.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s.pages.cols - 1, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
{ // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }
// Scroll up again try s.cursorDownScroll();
{ // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("3IJKL", contents); }
// Our selection should be null because it left the screen. { const sel = s.selection.?; try testing.expect(s.pages.pointFromPin(.active, sel.start()) == null); try testing.expect(s.pages.pointFromPin(.active, sel.end()) == null); }}
test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = -2 });
{ // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL\n1ABCD", contents); }
{ try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); }}
test "Screen: scrolling when viewport is pruned" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 215, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Write some to create scrollback and move back into our scrollback. try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = -2 });
// Our viewport is now somewhere pinned. Create so much scrollback // that we prune it. try s.testWriteString("\n"); for (0..1000) |_| try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{ try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, s.pages.getTopLeft(.viewport))); }}
test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
try s.scrollClear(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }}
test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH");
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); }
try s.scrollClear(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); }}
test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.scrollClear(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }}
test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); try s.scrollClear(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }
// Move back to top-left s.cursorAbsolute(0, 0);
// Write and clear try s.testWriteString("3ABCD\n"); { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("3ABCD", contents); }
try s.scrollClear(); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }
// Move back to top-left s.cursorAbsolute(0, 0); try s.testWriteString("X");
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3ABCD\nX", contents); }}
test "Screen: scroll above same page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// At this point: // +-------------+ ACTIVE // +----------+ : = PAGE 0 // 0 |1ABCD00000| | 0 // 1 |2EFGH00000| | 1 // :^ : : = PIN 0 // 2 |3IJKL00000| | 2 // +----------+ : // +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0 // 0 |1ABCD00000| // +-------------+ ACTIVE // 1 |2EFGH00000| | 0 // 2 | | | 1 // :^ : : = PIN 0 // 3 |3IJKL00000| | 2 // +----------+ : // +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0 row 2 (active row 1) is dirty because it was cleared. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); // Page 0 row 3 (active row 2) is dirty because it's new. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));}
test "Screen: scroll above same page but cursor on previous page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit();
// We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// Ensure we're still on the first page and have a second try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.pages.pages.first.?.next != null);
// At this point: // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4305 |1A00000000| | 0 // 4306 |2B00000000| | 1 // :^ : : = PIN 0 // 4307 |3C00000000| | 2 // +----------+ : // +----------+ : = PAGE 1 // 0 |4D00000000| | 3 // 1 |5E00000000| | 4 // +----------+ : // +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0 // ... : : // 4305 |1A00000000| // +-------------+ ACTIVE // 4306 |2B00000000| | 0 // 4307 | | | 1 // :^ : : = PIN 0 // +----------+ : // +----------+ : = PAGE 1 // 0 |3C00000000| | 2 // 1 |4D00000000| | 3 // 2 |5E00000000| | 4 // +----------+ : // +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0's penultimate row is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // The rest of the rows are dirty because they've been modified or are new. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));}
test "Screen: scroll above same page but cursor on previous page last row" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit();
// We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 2) |_| try s.testWriteString("\n"); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// Ensure we're still on the first page and have a second try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.pages.pages.first.?.next != null);
// At this point: // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4306 |1A00000000| | 0 // 4307 |2B00000000| | 1 // :^ : : = PIN 0 // +----------+ : // +----------+ : = PAGE 1 // 0 |3C00000000| | 2 // 1 |4D00000000| | 3 // 2 |5E00000000| | 4 // +----------+ : // +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0 // ... : : // 4306 |1A00000000| // +-------------+ ACTIVE // 4307 |2B00000000| | 0 // +----------+ : // +----------+ : = PAGE 1 // 0 | | | 1 // :^ : : = PIN 0 // 1 |3C00000000| | 2 // 2 |4D00000000| | 3 // 3 |5E00000000| | 4 // +----------+ : // +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0's final row is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 1's rows are all dirty because every row was moved. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 4 } }));
// Attempt to clear the style from the cursor and // then assert the integrity of both of our pages. // // This catches a case of memory corruption where the cursor // is moved between pages without accounting for style refs. try s.setAttribute(.{ .reset_bg = {} }); s.pages.pages.first.?.data.assertIntegrity(); s.pages.pages.last.?.data.assertIntegrity();}
test "Screen: scroll above creates new page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit();
// We need to get the cursor to a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// Ensure we're still on the first page try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
// At this point: // +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4305 |1ABCD00000| | 0 // 4306 |2EFGH00000| | 1 // :^ : : = PIN 0 // 4307 |3IJKL00000| | 2 // +----------+ : // +-------------+ try s.cursorScrollAbove();
// +----------+ = PAGE 0 // ... : : // 4305 |1ABCD00000| // +-------------+ ACTIVE // 4306 |2EFGH00000| | 0 // 4307 | | | 1 // :^ : : = PIN 0 // +----------+ : // +----------+ : = PAGE 1 // 0 |3IJKL00000| | 2 // +----------+ : // +-------------+
// try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0's penultimate row is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0's final row is dirty because it was cleared. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); // Page 1's row is dirty because it's new. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));}
test "Screen: scroll above with cursor on non-final row" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 10 }); defer s.deinit();
// Get the cursor to be 2 rows above a new page const first_page_size = s.pages.pages.first.?.data.capacity.rows; s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
// Write 3 lines of text, forcing the last line into the first // row of a new page. Move our cursor onto the previous page. try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1AB\n2BC\n3DE\n4FG"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// Ensure we're still on the first page. So our cursor is on the first // page but we have two pages of data. try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
// +----------+ = PAGE 0 // ... : : // +-------------+ ACTIVE // 4305 |1AB0000000| | 0 // 4306 |2BC0000000| | 1 // :^ : : = PIN 0 // 4307 |3DE0000000| | 2 // +----------+ : // +----------+ : = PAGE 1 // 0 |4FG0000000| | 3 // +----------+ : // +-------------+ try s.cursorScrollAbove();
// +----------+ = PAGE 0 // ... : : // 4305 |1AB0000000| // +-------------+ ACTIVE // 4306 |2BC0000000| | 0 // 4307 | | | 1 // :^ : : = PIN 0 // +----------+ : // +----------+ : = PAGE 1 // 0 |3DE0000000| | 2 // 1 |4FG0000000| | 3 // +----------+ : // +-------------+ // try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2BC\n\n3DE\n4FG", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0's penultimate row is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0's final row is dirty because it was cleared. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); // Page 1's row is dirty because it's new. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));}
test "Screen: scroll above no scrollback bottom of page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit();
const first_page_size = s.pages.pages.first.?.data.capacity.rows; s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.cursorAbsolute(0, 1); s.pages.clearDirty();
// At this point: // +-------------+ ACTIVE // +----------+ : = PAGE 0 // 0 |1ABCD00000| | 0 // 1 |2EFGH00000| | 1 // :^ : : = PIN 0 // 2 |3IJKL00000| | 2 // +----------+ : // +-------------+
try s.cursorScrollAbove();
// +----------+ = PAGE 0 // 0 |1ABCD00000| // +-------------+ ACTIVE // 1 |2EFGH00000| | 0 // 2 | | | 1 // :^ : : = PIN 0 // 3 |3IJKL00000| | 2 // +----------+ : // +-------------+
//try s.pages.diagram(std.io.getStdErr().writer());
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n\n3IJKL", contents); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expect(cell.content_tag == .bg_color_rgb); try testing.expectEqual(Cell.RGB{ .r = 155, .g = 0, .b = 0, }, cell.content.color_rgb); }
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); // Page 0 row 2 (active row 1) is dirty because it was cleared. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); // Page 0 row 3 (active row 2) is dirty because it is new. try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));}
test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone var s2 = try s.clone(alloc, .{ .active = .{} }, null); defer s2.deinit(); { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } try testing.expectEqual(@as(usize, 5), s2.cursor.x); try testing.expectEqual(@as(usize, 1), s2.cursor.y);
// Write to s1, should not be in s2 try s.testWriteString("\n34567"); { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n34567", contents); } { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } try testing.expectEqual(@as(usize, 5), s2.cursor.x); try testing.expectEqual(@as(usize, 1), s2.cursor.y);}
test "Screen: clone partial" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); defer s2.deinit(); { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH", contents); }
// Cursor is shifted since we cloned partial try testing.expectEqual(@as(usize, 5), s2.cursor.x); try testing.expectEqual(@as(usize, 0), s2.cursor.y);}
test "Screen: clone partial cursor out of bounds" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH", contents); } try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 1), s.cursor.y);
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 0 } }, ); defer s2.deinit(); { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); }
// Cursor is shifted since we cloned partial try testing.expectEqual(@as(usize, 0), s2.cursor.x); try testing.expectEqual(@as(usize, 0), s2.cursor.y);}
test "Screen: clone contains full selection" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{} }, null, ); defer s2.deinit();
// Our selection should remain valid { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s2.pages.cols - 1, .y = 1, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone contains none of selection" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 0 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, null, ); defer s2.deinit();
// Our selection should be null try testing.expect(s2.selection == null);}
test "Screen: clone contains selection start cutoff" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, null, ); defer s2.deinit();
// Our selection should remain valid { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s2.pages.cols - 1, .y = 0, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone contains selection end cutoff" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 1 } }, ); defer s2.deinit();
// Our selection should remain valid { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s2.pages.cols - 1, .y = 2, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone contains selection end cutoff reversed" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
// Select a single line try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 1 } }, ); defer s2.deinit();
// Our selection should remain valid { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s2.pages.cols - 1, .y = 2, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone contains subset of selection" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
// Select the full screen try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 0, .y = 3 } }).?, false, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, ); defer s2.deinit();
// Our selection should remain valid { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = s2.pages.cols - 1, .y = 3, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD");
// Select the full screen from x=1 to x=3 try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 3 } }).?, true, ));
// Clone var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, ); defer s2.deinit();
// Our selection should remain valid and be properly clipped // preserving the columns of the start and end points of the // selection. { const sel = s2.selection.?; try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, } }, s2.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 3, } }, s2.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL");
{ var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 1 } }, ); defer s2.deinit();
// Test our contents rotated const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH", contents); }
{ var s2 = try s.clone( alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, ); defer s2.deinit();
// Test our contents rotated const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }}
test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit();
{ var s2 = try s.clone( alloc, .{ .viewport = .{ .y = 0 } }, .{ .viewport = .{ .y = 0 } }, ); defer s2.deinit();
// Test our contents rotated const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }}
test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC");
{ var s2 = try s.clone( alloc, .{ .viewport = .{ .y = 0 } }, .{ .viewport = .{ .y = 0 } }, ); defer s2.deinit();
// Test our contents const contents = try s2.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABC", contents); }}
test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit();
{ var s2 = try s.clone( alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 0 } }, ); defer s2.deinit();
// Test our contents rotated const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }}
test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABC");
{ var s2 = try s.clone( alloc, .{ .active = .{ .y = 0 } }, null, ); defer s2.deinit();
// Test our contents rotated const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABC", contents); }}
test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); s.eraseHistory(null); try testing.expect(s.pages.viewport == .active); { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); }}
test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active);
// Scroll to top s.scroll(.{ .top = {} }); { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL", contents); }
s.eraseHistory(null); try testing.expect(s.pages.viewport == .active); { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); } { // Test our contents rotated const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("4ABCD\n5EFGH\n6IJKL", contents); }}
test "Screen: clear above cursor" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); s.clearRows( .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = s.cursor.y - 1 } }, false, ); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("\n\n6IJKL", contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("\n\n6IJKL", contents); }
try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 2), s.cursor.y);}
test "Screen: clear above cursor with history" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); s.clearRows( .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = s.cursor.y - 1 } }, false, ); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("\n\n6IJKL", contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", contents); }
try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 2), s.cursor.y);}
test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
// Resize try s.resize(.{ .cols = 10, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); try testing.expectEqual(5, s.cursor.x); try testing.expectEqual(2, s.cursor.y); try s.resize(.{ .cols = 10, .rows = 2, .reflow = false });
// Since we shrunk, we should adjust our cursor try testing.expectEqual(5, s.cursor.x); try testing.expectEqual(1, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2EFGH\n3IJKL", contents); }}
test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str);
// Write only a background color into the remaining rows for (1..s.pages.rows) |y| { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = @intCast(y), } }).?; list_cell.cell.* = .{ .content_tag = .bg_color_rgb, .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, }; }
const cursor = s.cursor; try s.resize(.{ .cols = 6, .rows = 2, .reflow = false });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); }}
test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str);
// Write only a background color into the remaining rows for (1..s.pages.rows) |y| { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = @intCast(y), } }).?; list_cell.cell.* = .{ .content_tag = .bg_color_rgb, .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } }, }; }
const cursor = s.cursor; try s.resize(.{ .cols = 10, .rows = 7, .reflow = false });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD", contents); }}
test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); try s.resize(.{ .cols = 20, .rows = 3, .reflow = false });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); try s.resize(.{ .cols = 4, .rows = 3, .reflow = false });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABC\n2EFG\n3IJK"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); try s.resize(.{ .cols = 7, .rows = 10, .reflow = false });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); try s.resize(.{ .cols = 7, .rows = 2, .reflow = false });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }}
// https://github.com/mitchellh/ghostty/issues/1030test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); try s.scrollClear(); s.cursorAbsolute(0, 0); try s.testWriteString("A\nB");
const cursor = s.cursor; try s.resize(.{ .cols = 5, .rows = 2, .reflow = false }); try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("A\nB", contents); }}
test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1A2B\n3C4E\n5F6G"; try s.testWriteString(str);
// Every second row should be wrapped for (0..6) |y| { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = @intCast(y), } }).?; const row = list_cell.row; const wrapped = (y % 2 == 0); try testing.expectEqual(wrapped, row.wrap); }
// Resize try s.resize(.{ .cols = 2, .rows = 10, .reflow = false }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1A\n2B\n3C\n4E\n5F\n6G"; try testing.expectEqualStrings(expected, contents); }
// Every second row should be wrapped for (0..6) |y| { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = @intCast(y), } }).?; const row = list_cell.row; const wrapped = (y % 2 == 0); try testing.expectEqual(wrapped, row.wrap); }}
test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; try s.resize(.{ .cols = 5, .rows = 10 });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); const cursor = s.cursor; try s.resize(.{ .cols = 5, .rows = 10 });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
// Set our cursor to be on the "4" s.cursorAbsolute(0, 1); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); }
// Resize try s.resize(.{ .cols = 5, .rows = 10 });
// Cursor should still be on the "4" { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); }
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
const cursor = s.cursor; try s.resize(.{ .cols = 10, .rows = 3 });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963test "Screen: resize more cols perfect split" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); try s.resize(.{ .cols = 10, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("1ABCD2EFGH\n3IJKL", contents); }}
// https://github.com/mitchellh/ghostty/issues/1159test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str);
// Cursor at bottom try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
s.scroll(.{ .delta_row = -4 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2\n3\n4", contents); }
try s.resize(.{ .cols = 8, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }
// Cursor remains at bottom try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);}
// https://github.com/mitchellh/ghostty/issues/1159test "Screen: resize (no reflow) less cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str);
// Cursor at bottom try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
s.scroll(.{ .delta_row = -4 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("2\n3\n4", contents); }
try s.resize(.{ .cols = 4, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .active = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("6\n7\n8", contents); }
// Cursor remains at bottom try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
// Old implementation doesn't do this but it makes sense to me: // { // const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); // defer alloc.free(contents); // try testing.expectEqualStrings("2\n3\n4", contents); // }}
test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit();
// Set one of the rows to be a prompt s.cursorSetSemanticContent(.output); try s.testWriteString("1ABCD\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("2EFGH"); s.cursorSetSemanticContent(.output); try s.testWriteString("\n3IJKL");
try s.resize(.{ .cols = 10, .rows = 3, .reflow = false });
const expected = "1ABCD\n2EFGH\n3IJKL"; { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents); }
// Our one row should still be a semantic prompt, the others should not. { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; try testing.expect(list_cell.row.semantic_prompt == .none); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?; try testing.expect(list_cell.row.semantic_prompt == .prompt); } { const list_cell = s.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; try testing.expect(list_cell.row.semantic_prompt == .none); }}
test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str);
// Verify we soft wrapped { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD\n2EFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Let's put our cursor on row 2, where the soft wrap is s.cursorAbsolute(0, 1); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); }
// Resize and verify we undid the soft wrap because we have space now try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }
// Our cursor should've moved try testing.expectEqual(@as(usize, 5), s.cursor.x); try testing.expectEqual(@as(usize, 0), s.cursor.y);}
test "Screen: resize more cols with reflow that ends in newline" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 6, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str);
// Verify we soft wrapped { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD2\nEFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Let's put our cursor on the last row s.cursorAbsolute(0, 2); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); }
// Resize and verify we undid the soft wrap because we have space now try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }
// Our cursor should still be on the 3 { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); }}
test "Screen: resize more cols with reflow that forces more wrapping" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is s.cursorAbsolute(0, 1); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); }
// Verify we soft wrapped { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD\n2EFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Resize and verify we undid the soft wrap because we have space now try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD2E\nFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Our cursor should've moved try testing.expectEqual(@as(size.CellCountInt, 5), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);}
test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is s.cursorAbsolute(0, 2); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); }
// Verify we soft wrapped { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD\n2EFGH\n3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Resize and verify we undid the soft wrap because we have space now try s.resize(.{ .cols = 15, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1ABCD2EFGH3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Our cursor should've moved try testing.expectEqual(@as(size.CellCountInt, 10), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);}
test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
// // Set our cursor to be on the "5" s.cursorAbsolute(0, 2); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); }
// Resize try s.resize(.{ .cols = 10, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "2EFGH\n3IJKL\n4ABCD5EFGH"; try testing.expectEqualStrings(expected, contents); }
// Cursor should still be on the "5" { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); }}
test "Screen: resize more cols bounded scrollback keeps viewport valid" { // Regression test for issue #12298. // // This needs to live at the Screen layer rather than PageList because the // bad state only appears once Screen forwards the active cursor into the // resize path. A direct PageList resize repro does not hit the same bug. const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 10, .max_scrollback = 10_000, }); defer s.deinit();
// Build 30 rows of scrollback on top of our 10-row viewport so we have a // 40-row screen with history above the active area. for (0..30) |_| _ = try s.pages.grow(); s.cursorReload(); try testing.expectEqual(@as(usize, 40), s.pages.scrollbar().total);
// Fill the entire screen with two-row wrapped runs: // - even rows mark the end of a wrapped line // - odd rows mark the continuation // // With 2 columns, each logical line occupies two rows. When we grow to 4 // columns with reflow enabled, those pairs unwrap back into single rows. // That cuts the total row count down and is what stresses the viewport pin. var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { const page = &chunk.node.data; for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { rac.row.wrap = true; } else { rac.row.wrap_continuation = true; }
for (0..s.pages.cols) |x| { page.getRowAndCell(x, y).cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 'A' }, }; } } }
// Pin the viewport to a history row just above the active area. // // Before resize: // - total rows = 40 // - active area starts at row 30 // - viewport is pinned at row 28 // // After unwrap during resize: // - total rows shrinks to 20 // - the old row 28 remaps into what is now the active area // // The bug was that resize/grow would temporarily keep the viewport as a // history pin even after reflow had moved it into the active area, leaving // fewer than `rows` visible rows beneath the pin and tripping integrity // checks. s.pages.scroll(.{ .pin = s.pages.pin(.{ .screen = .{ .y = 28 } }).? }); try testing.expect(s.pages.viewport == .pin); try testing.expect(s.pages.getBottomRight(.viewport) != null);
// Growing columns triggers reflow, which unwraps the synthetic wrapped // rows above. This used to panic during the resize path. try s.resize(.{ .cols = 4, .rows = s.pages.rows, .reflow = true });
// After the fix, the viewport is normalized back to the active area as // soon as the pinned row lands there, so viewport queries remain valid. try testing.expectEqual(@as(usize, 4), s.pages.cols); try testing.expect(s.pages.scrollbar().total < 40); try testing.expect(s.pages.viewport == .active); try testing.expect(s.pages.getBottomRight(.viewport) != null);}
test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABC\n2DEF\n3ABC\n4DEF"; try s.testWriteString(str);
// Let's put our cursor on row 2, where the soft wrap is s.cursorAbsolute(0, 2); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); }
// Verify we soft wrapped { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "BC\n4D\nEF"; try testing.expectEqualStrings(expected, contents); }
// Resize and verify we undid the soft wrap because we have space now try s.resize(.{ .cols = 7, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "1ABC\n2DEF\n3ABC\n4DEF"; try testing.expectEqualStrings(expected, contents); }
// Our cursor should've moved try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);}
test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 4, .max_scrollback = 0 }); defer s.deinit(); const str = "1A2B\n3C4D"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1A\n2B\n3C\n4D"; try testing.expectEqualStrings(expected, contents); }
try s.resize(.{ .cols = 5, .rows = 10 });
// Cursor should move due to wrapping try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
s.cursorAbsolute(0, 0); const cursor = s.cursor; try s.resize(.{ .cols = 5, .rows = 1 });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
// Put our cursor on the last line s.cursorAbsolute(1, 2); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint); }
// Resize try s.resize(.{ .cols = 5, .rows = 1 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); }
// Cursor should be on the last line try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);}
test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); try s.resize(.{ .cols = 5, .rows = 1 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
// Resize try s.resize(.{ .cols = 5, .rows = 1 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "5EFGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
// Resize try s.resize(.{ .cols = 5, .rows = 2 });
// Cursor should stay in the same relative place (bottom of the // screen, same character). try testing.expectEqual(@as(size.CellCountInt, 4), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str);
s.cursorAbsolute(0, 0); const cursor = s.cursor; try s.resize(.{ .cols = 3, .rows = 3 });
// Cursor should not move try testing.expectEqual(cursor.x, s.cursor.x); try testing.expectEqual(cursor.y, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str);
// Put our cursor on the end s.cursorAbsolute(4, 0); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); }
try s.resize(.{ .cols = 3, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "1AB\nCD"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "1AB\nCD"; try testing.expectEqualStrings(expected, contents); }
// Cursor should be on the last line try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.y);}
test "Screen: resize less cols with reflow with trimmed rows" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); try s.resize(.{ .cols = 3, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "CD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "CD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); try s.resize(.{ .cols = 3, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "CD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "3IJKL4ABCD5EFGH"; try s.testWriteString(str);
// Check { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
try s.resize(.{ .cols = 3, .rows = 3 });
// { // const contents = try s.testString(alloc, .viewport); // defer alloc.free(contents); // const expected = "CD\n5EF\nGH"; // try testing.expectEqualStrings(expected, contents); // } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "ABC\nD5E\nFGH"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str);
// Put our cursor on the end s.cursorAbsolute(1, s.pages.rows - 1); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); }
try s.resize(.{ .cols = 3, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3C\n4D\n5E"; try testing.expectEqualStrings(expected, contents); }
// Cursor should be on the last line try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);}
test "Screen: resize less cols with reflow previously wrapped and scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 2 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str);
// Check { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "3IJKL\n4ABCD\n5EFGH"; try testing.expectEqualStrings(expected, contents); }
// Put our cursor on the end s.cursorAbsolute(s.pages.cols - 1, s.pages.rows - 1); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); }
try s.resize(.{ .cols = 3, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "CD5\nEFG\nH"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH"; try testing.expectEqualStrings(expected, contents); }
// Cursor should be on the last line try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); { const list_cell = s.pages.getCell(.{ .active = .{ .x = s.cursor.x, .y = s.cursor.y, } }).?; try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); }}
test "Screen: resize less cols with scrollback keeps cursor row" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str);
// Lets do a scroll and clear operation try s.scrollClear();
// Move our cursor to the beginning s.cursorAbsolute(0, 0);
try s.resize(.{ .cols = 3, .rows = 3 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = ""; try testing.expectEqualStrings(expected, contents); }
// Cursor should be on the last line try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x); try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.y);}
test "Screen: resize more rows, less cols with reflow with scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); defer s.deinit(); const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; try s.testWriteString(str);
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "2EFGH\n3IJKL\n4MNOP"; try testing.expectEqualStrings(expected, contents); }
try s.resize(.{ .cols = 2, .rows = 10 });
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; try testing.expectEqualStrings(expected, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); const expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP"; try testing.expectEqualStrings(expected, contents); }}
// This seems like it should work fine but for some reason in practice// in the initial implementation I found this bug! This is a regression// test for that.test "Screen: resize more rows then shrink again" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); defer s.deinit(); const str = "1ABC"; try s.testWriteString(str);
// Grow try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }
// Shrink try s.resize(.{ .cols = 5, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }
// Grow again try s.resize(.{ .cols = 5, .rows = 10 }); { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); }}
test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); }
// Resize to 1 column can't fit a wide char. So it should be deleted. try s.resize(.{ .cols = 1, .rows = 1 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expectEqual(Cell.Wide.narrow, cell.wide); }}
test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "x😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }
try s.resize(.{ .cols = 2, .rows = 3 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("x\n😀", contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); try testing.expect(list_cell.row.wrap); }}
test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }
try s.resize(.{ .cols = 1, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("", contents); }}
test "Screen: resize less cols reflows cursor after wrapped text" { const testing = std.testing; const alloc = testing.allocator; var s = try Screen.init(alloc, .{ .cols = 50, .rows = 7, .max_scrollback = 0 }); defer s.deinit();
for (0..30) |_| try s.testWriteString("a");
try testing.expectEqual(@as(usize, 0), s.cursor.y); try testing.expectEqual(@as(usize, 30), s.cursor.x);
try s.resize(.{ .cols = 25, .rows = 7 });
try testing.expectEqual(@as(usize, 1), s.cursor.y); try testing.expectEqual(@as(usize, 5), s.cursor.x);}
test "Screen: resize less cols reflows cursor after empty cells" { const testing = std.testing; const alloc = testing.allocator; var s = try Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit();
try s.testWriteString("abc"); s.cursorRight(6);
try testing.expectEqual(@as(usize, 0), s.cursor.y); try testing.expectEqual(@as(usize, 9), s.cursor.x);
try s.resize(.{ .cols = 5, .rows = 3 });
try testing.expectEqual(@as(usize, 1), s.cursor.y); try testing.expectEqual(@as(usize, 4), s.cursor.x);}
test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 3, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = " 😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(" \n😀", contents); }
// So this is the key point: we end up with a wide spacer head at // the end of row 1, then the emoji, then a wide spacer tail on row 2. // We should expect that if we resize to more cols, the wide spacer // head is replaced with the emoji. { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }
try s.resize(.{ .cols = 4, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }}
test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "xxxyy😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("xxx\nyy\n😀", contents); }
// Similar to the "wide spacer head" test, but this time we'er going // to increase our columns such that multiple rows are unwrapped. { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 2 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 2 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }
try s.resize(.{ .cols = 8, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(str, contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }}
test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); defer s.deinit(); const str = "xx😀"; try s.testWriteString(str); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("xx\n😀", contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }
// This resizes to 3 columns, which isn't enough space for our wide // char to enter row 1. But we need to mark the wide spacer head on the // end of the first row since we're wrapping to the next row. try s.resize(.{ .cols = 3, .rows = 2 }); { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("xx\n😀", contents); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); }}
test "Screen: resize more cols with cursor at prompt" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit();
// zig fmt: off try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_eol }); try s.testWriteString("echo"); // zig fmt: on
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE\n> echo"; try testing.expectEqualStrings(expected, contents); }
try s.resize(.{ .cols = 20, .rows = 3, .prompt_redraw = .true, });
// Cursor should not move try testing.expectEqual(6, s.cursor.x); try testing.expectEqual(1, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize more cols with cursor not at prompt" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); defer s.deinit();
// zig fmt: off try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_eol }); try s.testWriteString("echo\n"); try s.testWriteString("output"); // zig fmt: on
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE\n> echo\noutput"; try testing.expectEqualStrings(expected, contents); }
try s.resize(.{ .cols = 20, .rows = 3, .prompt_redraw = .true, });
// Cursor should not move try testing.expectEqual(6, s.cursor.x); try testing.expectEqual(2, s.cursor.y);
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE\n> echo\noutput"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize with prompt_redraw last clears only one line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); defer s.deinit();
// zig fmt: off try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello\n"); try s.testWriteString("world"); // zig fmt: on
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE\n> hello\nworld"; try testing.expectEqualStrings(expected, contents); }
// Cursor is at end of "world" line with semantic_content = .input try s.resize(.{ .cols = 20, .rows = 4, .prompt_redraw = .last, });
// With .last, only the current line where cursor is should be cleared { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "ABCDE\n> hello"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); defer s.deinit();
// Create a 3-line prompt: 1 initial + 2 continuation lines // zig fmt: off s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("line1\n"); s.cursorSetSemanticContent(.{ .prompt = .continuation }); try s.testWriteString("line2\n"); s.cursorSetSemanticContent(.{ .prompt = .continuation }); try s.testWriteString("line3"); // zig fmt: on
{ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "line1\nline2\nline3"; try testing.expectEqualStrings(expected, contents); }
// Cursor is at end of line3 (the last continuation line) try s.resize(.{ .cols = 30, .rows = 5, .prompt_redraw = .last, });
// With .last, only line3 (where cursor is) should be cleared { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); const expected = "line1\nline2"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456");
try testing.expect(s.selection == null); const tracked = s.pages.countTrackedPins(); try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, )); try testing.expectEqual(tracked + 2, s.pages.countTrackedPins()); try s.select(null); try testing.expectEqual(tracked, s.pages.countTrackedPins());}
test "Screen: select replaces existing pins" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456");
const tracked = s.pages.countTrackedPins(); try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?, false, )); try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());
// Replacing the selection must untrack the prior selection's pins // rather than leak them. try s.select(Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?, false, )); try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());}
test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
{ try s.testWriteString("ABC DEF\n 123\n456"); var sel = s.selectAll().?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
{ try s.testWriteString("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678"); var sel = s.selectAll().?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 8, .y = 7, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456");
// Outside of active area // try testing.expect(s.selectLine(.{ .x = 13, .y = 0 }) == null); // try testing.expect(s.selectLine(.{ .x = 0, .y = 5 }) == null);
// Going forward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going backward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 7, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going forward and backward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Outside active area { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 9, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 7, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123");
// Going forward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine across full soft-wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1ABCD2EFGH\n3IJKL");
{ var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 1, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123");
// Going forward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going backward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going forward and backward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine disabled whitespace trimming" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123");
// Going forward { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).?, .whitespace = null, }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 2, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Non-wrapped { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 3, } }).?, .whitespace = null, }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 3, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 3, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); defer s.deinit(); try s.testWriteString("1A\n2B\n3C\n4D\n5E");
// Selecting first line { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
// Selecting last line { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 2, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 2, } }, s.pages.pointFromPin(.active, sel.end()).?); }}
// https://github.com/mitchellh/ghostty/issues/1329test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("A "); s.cursorSetSemanticContent(.output); try s.testWriteString("> ");
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("ABCDE\nA \n> ", contents); }
// Selecting output stops at the prompt even if soft-wrapped { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); const expected = "A"; try testing.expectEqualStrings(expected, contents); } { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 2, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 2, } }, s.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: selectLine semantic prompt to input boundary" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Write prompt followed by user input on same row: "$>command" // Using non-whitespace to avoid whitespace trimming affecting the test s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("$>"); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("command");
// Selecting from prompt should only select prompt { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
// Selecting from input should only select input { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 8, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: selectLine semantic input to output boundary" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Row 0: user input s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("ls -la\n"); // Row 1: command output s.cursorSetSemanticContent(.output); try s.testWriteString("file.txt");
// Selecting from input should only select input { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("ls -la", contents); }
// Selecting from output should only select output { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 1, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("file.txt", contents); }}
test "Screen: selectLine semantic mid-row boundary" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Single row with output then prompt then input: "out$>cmd" // Using non-whitespace to avoid whitespace trimming affecting the test s.cursorSetSemanticContent(.output); try s.testWriteString("out"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("$>"); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("cmd");
// Selecting from output should stop at prompt { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
// Selecting from prompt should only select prompt { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 3, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
// Selecting from input should only select input { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 6, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 5, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 7, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Row 0: prompt "$ " + input "cmd" (soft-wraps) // Row 1: input continues "12" + output "out" s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("$ "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("cmd12"); s.cursorSetSemanticContent(.output); try s.testWriteString("out");
// Verify layout { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("$ cmd\n12out", contents); }
// Selecting from input on row 0 should get all input across soft-wrap { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("cmd12", contents); }
// Selecting from input on row 1 should get all input across soft-wrap { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 1, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("cmd12", contents); }
// Selecting from output should only get output { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 3, .y = 1, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("out", contents); }}
test "Screen: selectLine semantic boundary disabled" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Write prompt followed by input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("$ "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("command");
// With semantic_prompt_boundary = false, should select entire line { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0, } }).?, .semantic_prompt_boundary = false, }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("$ command", contents); }}
test "Screen: selectLine semantic boundary first cell of row" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Row 0: input that soft-wraps // Row 1: output starts at first cell s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("12345"); s.cursorSetSemanticContent(.output); try s.testWriteString("ABCDE");
// Verify soft-wrap happened { const pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?; const row = pin.rowAndCell().row; try testing.expect(row.wrap); }
// Selecting from input should stop before output on row 1 { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.active, sel.end()).?); }
// Selecting from output should only get output { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 1, } }).? }).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 1, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 4, .y = 1, } }, s.pages.pointFromPin(.active, sel.end()).?); }}
test "Screen: selectLine semantic all same content" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// All prompt content that soft-wraps s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("prompt text");
// Verify soft-wrap { const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("promp\nt tex\nt", contents); }
// Should select all prompt content across soft-wraps { var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 1, } }).? }).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("prompt text", contents); }}
test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456");
// Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', };
// Outside of active area // try testing.expect(s.selectWord(.{ .x = 9, .y = 0 }) == null); // try testing.expect(s.selectWord(.{ .x = 0, .y = 5 }) == null);
// Going forward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going forward and backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Whitespace { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 3, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Whitespace single char { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 0, .y = 1, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// End of screen { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 2, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 2, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 2, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(" 1234012\n 123");
// Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', };
{ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); defer alloc.free(contents); try testing.expectEqualStrings(" 1234\n012\n 123", contents); }
// Going forward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going forward and backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString("1 1\n 123");
// Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', };
// Going forward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Going forward and backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 1, } }, s.pages.pointFromPin(.screen, sel.end()).?); }}
test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator;
// Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', };
const cases = [_][]const u8{ " 'abc' \n123", " \"abc\" \n123", " │abc│ \n123", " `abc` \n123", " |abc| \n123", " :abc: \n123", " ;abc; \n123", " ,abc, \n123", " (abc( \n123", " )abc) \n123", " [abc[ \n123", " ]abc] \n123", " {abc{ \n123", " }abc} \n123", " <abc< \n123", " >abc> \n123", " $abc$ \n123", };
for (cases) |case| { var s = try init(alloc, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); try s.testWriteString(case);
// Inside character forward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 2, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Inside character backward { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 4, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// Inside character bidirectional { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 3, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 2, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 4, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); }
// On quote // NOTE: this behavior is not ideal, so we can change this one day, // but I think its also not that important compared to the above. { var sel = s.selectWord(s.pages.pin(.{ .active = .{ .x = 1, .y = 0, } }).?, boundary_codepoints).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .screen = .{ .x = 0, .y = 0, } }, s.pages.pointFromPin(.screen, sel.start()).?); try testing.expectEqual(point.Point{ .screen = .{ .x = 1, .y = 0, } }, s.pages.pointFromPin(.screen, sel.end()).?); } }}
test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); defer s.deinit();
// Build content with cell-level semantic content: // Row 0-1: output1 (output) // Row 2: prompt2 (prompt) // Row 3: input2 (input) // Row 4-7: output2 (output, with overflow causing wrap) // Row 8: "$ " (prompt) + "input3" (input) // Row 9-11: output3 (output) s.cursorSetSemanticContent(.output); try s.testWriteString("output1\n"); try s.testWriteString("output1\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("prompt2\n"); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("input2\n"); s.cursorSetSemanticContent(.output); try s.testWriteString("output2output2output2output2\n"); try s.testWriteString("output2\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("$ "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("input3\n"); s.cursorSetSemanticContent(.output); try s.testWriteString("output3\n"); try s.testWriteString("output3\n"); try s.testWriteString("output3");
// First output block (rows 0-1), should select those rows { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 1, } }).?).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings("output1\noutput1", contents); } // Second output block (rows 4-7) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 3, .y = 7, } }).?).?; defer sel.deinit(&s); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); try testing.expectEqualStrings( "output2output2output2output2\noutput2", contents, ); } // Third output block (rows 9-11) { var sel = s.selectOutput(s.pages.pin(.{ .active = .{ .x = 2, .y = 10, } }).?).?; defer sel.deinit(&s); try testing.expectEqual(point.Point{ .active = .{ .x = 0, .y = 9, } }, s.pages.pointFromPin(.active, sel.start()).?); try testing.expectEqual(point.Point{ .active = .{ .x = 6, .y = 11, } }, s.pages.pointFromPin(.active, sel.end()).?); } // Click on prompt should return null { try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ .x = 1, .y = 8, } }).?) == null); } // Click on input should return null { try testing.expect(s.selectOutput(s.pages.pin(.{ .active = .{ .x = 5, .y = 8, } }).?) == null); }}
test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "2EFGH\n3IJ"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = ""; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 2 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 6 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "3IJKL"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str);
const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, false, );
{ const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "1AB\n2EF"; try testing.expectEqualStrings(expected, contents); }
// No trim { const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); const expected = "1AB \n2EF"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str);
const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, );
{ const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "1AB\n\n2EF"; try testing.expectEqualStrings(expected, contents); }
// No trim { const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(contents); const expected = "1AB \n\n2EF"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 2 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "2EFGH3IJ"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1A⚡"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); }
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); }
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 3, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "⚡"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABC⚡"; try s.testWriteString(str);
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 4, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = str; try testing.expectEqualStrings(expected, contents); }}
// https://github.com/mitchellh/ghostty/issues/289test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 }); defer s.deinit();
// Let me describe the situation that caused this because this // test is not obvious. By writing an emoji below, we introduce // one cell with the emoji and one cell as a "wide char spacer". // We then soft wrap the line by writing spaces. // // By selecting only the tail, we'd select nothing and we had // a logic error that would cause a crash. try s.testWriteString("👨"); try s.testWriteString(" ");
{ const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 2, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "👨"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); defer s.deinit(); const str = "👨"; // this has a ZWJ try s.testWriteString(str);
// Integrity check { const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?; const cell = pin.rowAndCell().cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = pin.node.data.lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); }
// The real test { const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 1, .y = 0 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "👨"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor \\sit amet, consectetur \\adipiscing elit, sed do \\eiusmod tempor incididunt \\ut labore et dolore ; const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?, s.pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?, true, ); const expected = \\t ame \\ipisc \\usmod ; try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents);}
test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor \\sit amet, consectetur \\adipiscing elit, sed do \\eiusmod tempor incididunt \\ut labore et dolore ; const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?, s.pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?, true, ); const expected = \\dolor \\nsectetur \\lit, sed do \\or incididunt \\ dolore ; try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents);}
test "Screen: selectionString, rectangle, more complex w/breaks" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 30, .rows = 8, .max_scrollback = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor \\sit amet, consectetur \\adipiscing elit, sed do \\eiusmod tempor incididunt \\ut labore et dolore \\ \\magna aliqua. Ut enim \\ad minim veniam, quis ; const sel = Selection.init( s.pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?, s.pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?, true, ); const expected = \\elit, sed do \\por incididunt \\t dolore \\ \\a. Ut enim \\niam, quis ; try s.testWriteString(str);
const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); try testing.expectEqualStrings(expected, contents);}
test "Screen: selectionString multi-page" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit();
const first_page_size = s.pages.pages.first.?.data.capacity.rows;
// Lazy way to seek to the first page boundary. s.pages.pages.first.?.data.pauseIntegrityChecks(true); for (0..first_page_size - 1) |_| { try s.testWriteString("\n"); } s.pages.pages.first.?.data.pauseIntegrityChecks(false);
try s.testWriteString("123456789\n!@#$%^&*(\n123456789");
{ const sel = Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?, false, ); const contents = try s.selectionString(alloc, .{ .sel = sel, .trim = true, }); defer alloc.free(contents); const expected = "123456789\n!@#$%^&*(\n123"; try testing.expectEqualStrings(expected, contents); }}
test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH"; try s.testWriteString(str);
// Test the line iterator var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); { const sel = iter.next().?; const actual = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(actual); try testing.expectEqualStrings("1ABCD", actual); } { const sel = iter.next().?; const actual = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(actual); try testing.expectEqualStrings("2EFGH", actual); }}
test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3ABCD"; try s.testWriteString(str);
// Test the line iterator var iter = s.lineIterator(s.pages.pin(.{ .viewport = .{} }).?); { const sel = iter.next().?; const actual = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(actual); try testing.expectEqualStrings("1ABCD2EFGH", actual); } { const sel = iter.next().?; const actual = try s.selectionString(alloc, .{ .sel = sel, .trim = false, }); defer alloc.free(actual); try testing.expectEqualStrings("3ABCD", actual); } // try testing.expect(iter.next() == null);}
test "Screen: hyperlink start/end" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(0, page.hyperlink_set.count()); }
try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(1, page.hyperlink_set.count()); }
s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(0, page.hyperlink_set.count()); }}
test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
try testing.expect(s.cursor.hyperlink_id == 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(0, page.hyperlink_set.count()); }
// Use it for the first time try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); const id = s.cursor.hyperlink_id;
// Reuse the same hyperlink, expect we have the same ID try s.startHyperlink("http://example.com", null); try testing.expectEqual(id, s.cursor.hyperlink_id); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(1, page.hyperlink_set.count()); }
s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(0, page.hyperlink_set.count()); }}
test "Screen: hyperlink cursor state on resize" { const testing = std.testing; const alloc = testing.allocator;
// This test depends on underlying PageList implementation so // it may be invalid one day. It's here to document/verify the // current behavior.
var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); defer s.deinit();
// Start a hyperlink try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(1, page.hyperlink_set.count()); }
// Resize. Any column growth will trigger a page to be reallocated. try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(1, page.hyperlink_set.count()); }
s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { const page = &s.cursor.page_pin.node.data; try testing.expectEqual(0, page.hyperlink_set.count()); }}
test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); defer s.deinit();
// Start a hyperlink with a URI that just barely fits in the string alloc. // This will ensure that additional string alloc space is needed for the // redundant copy of the URI when the page is re-alloced. const uri = "a" ** (pagepkg.std_capacity.string_bytes - 8); try s.startHyperlink(uri, null);
// Figure out how many cells should can have hyperlinks in this page, // and write twice that number, to guarantee the capacity needs to be // increased at some point. const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity(); const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes; for (0..base_capacity * 2) |_| { try s.cursorSetHyperlink(); if (s.cursor.x >= s.pages.cols - 1) { try s.cursorDownOrScroll(); s.cursorHorizontalAbsolute(0); } else { s.cursorRight(1); } }
// Make sure the capacity really did increase. try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity()); // And that our string_bytes increased as well. try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes);}
test "Screen: increaseCapacity cursor style ref count preserved" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0, }); defer s.deinit(); try s.setAttribute(.bold); try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page try testing.expect(s.pages.pages.first == s.pages.pages.last); try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
const old_style = s.cursor.style;
{ const page = &s.pages.pages.last.?.data; // 5 chars + cursor = 6 refs try testing.expectEqual( 6, page.styles.refCount(page.memory, s.cursor.style_id), ); }
// This forces the page to change via increaseCapacity. const new_node = try s.increaseCapacity( s.cursor.page_pin.node, .grapheme_bytes, );
// Cursor's page_pin should now point to the new node try testing.expect(s.cursor.page_pin.node == new_node);
// Verify cursor's page_cell and page_row are correctly reloaded from the pin const page_rac = s.cursor.page_pin.rowAndCell(); try testing.expect(s.cursor.page_row == page_rac.row); try testing.expect(s.cursor.page_cell == page_rac.cell);
// Style should be preserved try testing.expectEqual(old_style, s.cursor.style); try testing.expect(s.cursor.style_id != style.default_id);
// After increaseCapacity, the 5 chars are cloned (5 refs) and // the cursor's style is re-added (1 ref) = 6 total. { const page = &s.pages.pages.last.?.data; const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); }}
test "Screen: increaseCapacity cursor hyperlink ref count preserved" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0, }); defer s.deinit(); try s.startHyperlink("https://example.com/", null); try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page try testing.expect(s.pages.pages.first == s.pages.pages.last); try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
{ const page = &s.pages.pages.last.?.data; // Cursor has the hyperlink active = 1 count in hyperlink_set try testing.expectEqual(1, page.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); try testing.expect(s.cursor.hyperlink != null); }
// This forces the page to change via increaseCapacity. _ = try s.increaseCapacity( s.cursor.page_pin.node, .grapheme_bytes, );
// Hyperlink should be preserved with correct URI try testing.expect(s.cursor.hyperlink != null); try testing.expect(s.cursor.hyperlink_id != 0); try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);
// After increaseCapacity, the hyperlink is re-added to the new page. { const page = &s.pages.pages.last.?.data; try testing.expectEqual(1, page.hyperlink_set.count()); }}
test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0, }); defer s.deinit();
// Set both a non-default style AND an active hyperlink. // Write one character first with bold to mark the row as styled, // then start the hyperlink and write more characters. try s.setAttribute(.bold); try s.startHyperlink("https://example.com/", null); try s.testWriteString("1ABCD");
// We should have one page and it should be our cursor page try testing.expect(s.pages.pages.first == s.pages.pages.last); try testing.expect(s.pages.pages.first == s.cursor.page_pin.node);
const old_style = s.cursor.style;
{ const page = &s.pages.pages.last.?.data; // 5 chars + cursor = 6 refs for bold style try testing.expectEqual( 6, page.styles.refCount(page.memory, s.cursor.style_id), ); // Cursor has the hyperlink active = 1 count in hyperlink_set try testing.expectEqual(1, page.hyperlink_set.count()); try testing.expect(s.cursor.style_id != style.default_id); try testing.expect(s.cursor.hyperlink_id != 0); try testing.expect(s.cursor.hyperlink != null); }
// This forces the page to change via increaseCapacity. _ = try s.increaseCapacity( s.cursor.page_pin.node, .grapheme_bytes, );
// Style should be preserved try testing.expectEqual(old_style, s.cursor.style); try testing.expect(s.cursor.style_id != style.default_id);
// Hyperlink should be preserved with correct URI try testing.expect(s.cursor.hyperlink != null); try testing.expect(s.cursor.hyperlink_id != 0); try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);
// After increaseCapacity, both style and hyperlink are re-added to the new page. { const page = &s.pages.pages.last.?.data; const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); try testing.expectEqual(1, page.hyperlink_set.count()); }}
test "Screen: increaseCapacity non-cursor page returns early" { // Test that calling increaseCapacity on a page that is NOT the cursor's // page properly delegates to pages.increaseCapacity without doing the // extra cursor accounting (style/hyperlink re-adding). const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 10000, }); defer s.deinit();
// Set up a custom style and hyperlink on the cursor try s.setAttribute(.bold); try s.startHyperlink("https://example.com/", null); try s.testWriteString("Hello");
// Store cursor state before growing pages const old_style = s.cursor.style; const old_style_id = s.cursor.style_id; const old_hyperlink = s.cursor.hyperlink; const old_hyperlink_id = s.cursor.hyperlink_id;
// The cursor is on the first (and only) page try testing.expect(s.pages.pages.first == s.pages.pages.last); try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?);
// Grow pages until we have multiple pages. The cursor's pin stays on // the first page since we're just adding rows. const first_page_node = s.pages.pages.first.?; first_page_node.data.pauseIntegrityChecks(true); for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| { _ = try s.pages.grow(); } first_page_node.data.pauseIntegrityChecks(false); _ = try s.pages.grow();
// Now we have two pages try testing.expect(s.pages.pages.first != s.pages.pages.last); const second_page = s.pages.pages.last.?;
// Cursor should still be on the first page (where it was created) try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.cursor.page_pin.node != second_page);
const second_page_styles_cap = second_page.data.capacity.styles; const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles;
// Call increaseCapacity on the second page (NOT the cursor's page) const new_second_page = try s.increaseCapacity(second_page, .styles);
// The second page should have increased capacity try testing.expectEqual( second_page_styles_cap * 2, new_second_page.data.capacity.styles, );
// The cursor's page (first page) should be unchanged try testing.expectEqual( cursor_page_styles_cap, s.cursor.page_pin.node.data.capacity.styles, );
// Cursor state should be completely unchanged since we didn't touch its page try testing.expectEqual(old_style, s.cursor.style); try testing.expectEqual(old_style_id, s.cursor.style_id); try testing.expectEqual(old_hyperlink, s.cursor.hyperlink); try testing.expectEqual(old_hyperlink_id, s.cursor.hyperlink_id);
// Verify hyperlink is still valid try testing.expect(s.cursor.hyperlink != null); try testing.expectEqualStrings("https://example.com/", s.cursor.hyperlink.?.uri);}
test "Screen: cursorDown to page with insufficient capacity" { // Regression test for https://github.com/ghostty-org/ghostty/issues/10282 // // This test exposes a use-after-realloc bug in cursorDown (and similar // cursor movement functions). The bug pattern: // // 1. cursorDown creates a by-value copy of the pin via page_pin.down(n) // 2. cursorChangePin is called, which may trigger adjustCapacity // if the target page's style map is full // 3. adjustCapacity frees the old page and creates a new one // 4. The local pin copy still points to the freed page // 5. rowAndCell() on the stale pin accesses freed memory
const testing = std.testing; const alloc = testing.allocator;
// Small screen to make page boundary crossing easy to set up var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); defer s.deinit();
// Scroll down enough to create a second page const start_page = &s.pages.pages.last.?.data; const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false);
// Cursor should now be on a new page const new_page = &s.cursor.page_pin.node.data; try testing.expect(start_page != new_page);
// Fill new_page's style map to capacity. When we move INTO this page // with a style set, adjustCapacity will be triggered. { new_page.pauseIntegrityChecks(true); defer new_page.pauseIntegrityChecks(false); defer new_page.assertIntegrity();
var n: u24 = 1; while (new_page.styles.add( new_page.memory, .{ .bg_color = .{ .rgb = @bitCast(n) } }, )) |_| n += 1 else |_| {} }
// Move cursor to start of active area and set a style s.cursorAbsolute(0, 0); try s.setAttribute(.bold); try testing.expect(s.cursor.style.flags.bold); try testing.expect(s.cursor.style_id != style.default_id);
// Find the row just before the page boundary for (0..s.pages.rows - 1) |row| { s.cursorAbsolute(0, @intCast(row)); const cur_node = s.cursor.page_pin.node; if (s.cursor.page_pin.down(1)) |next_pin| { if (next_pin.node != cur_node) { // Cursor is at 'row', moving down crosses to new_page try testing.expect(&next_pin.node.data == new_page);
// This cursorDown triggers the bug: the local page_pin copy // becomes stale after adjustCapacity, causing rowAndCell() // to access freed memory. s.cursorDown(1);
// If the fix is applied, verify correct state try testing.expect(s.cursor.y == row + 1); try testing.expect(s.cursor.style.flags.bold);
break; } } } else { // Didn't find boundary try testing.expect(false); }}
test "Screen setAttribute increases capacity when style map is full" { // Tests that setAttribute succeeds when the style map is full by // increasing page capacity. When capacity is at max and increaseCapacity // returns OutOfSpace, manualStyleUpdate will split the page instead. const testing = std.testing; const alloc = testing.allocator;
// Use a small screen with multiple rows var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); defer s.deinit();
// Write content to multiple rows try s.testWriteString("line1\nline2\nline3\nline4\nline5");
// Get the page and fill its style map to capacity const page = &s.cursor.page_pin.node.data; const original_styles_capacity = page.capacity.styles;
// Fill the style map to capacity using the StyleSet's layout capacity // which accounts for the load factor { page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); defer page.assertIntegrity();
const max_items = page.styles.layout.cap; var n: usize = 1; while (n < max_items) : (n += 1) { _ = page.styles.add( page.memory, .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, ) catch break; } }
// Now try to set a new unique attribute that would require a new style slot // This should succeed by increasing capacity (or splitting if at max capacity) try s.setAttribute(.bold);
// The style should have been applied (bold flag set) try testing.expect(s.cursor.style.flags.bold);
// The cursor should have a valid non-default style_id try testing.expect(s.cursor.style_id != style.default_id);
// Either the capacity increased or the page was split/changed const current_page = &s.cursor.page_pin.node.data; const capacity_increased = current_page.capacity.styles > original_styles_capacity; const page_changed = current_page != page; try testing.expect(capacity_increased or page_changed);}
test "Screen setAttribute splits page on OutOfSpace at max styles" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0, }); defer s.deinit();
// Write content to multiple rows so we have something to split try s.testWriteString("line1\nline2\nline3\nline4\nline5");
// Remember the original node const original_node = s.cursor.page_pin.node;
// Increase the page's style capacity to max by repeatedly calling increaseCapacity // Use Screen.increaseCapacity to properly maintain cursor state const max_styles = std.math.maxInt(size.CellCountInt); while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { _ = s.increaseCapacity( s.cursor.page_pin.node, .styles, ) catch break; }
// Get the page reference after increaseCapacity - cursor may have moved var page = &s.cursor.page_pin.node.data; try testing.expectEqual(max_styles, page.capacity.styles);
// Fill the style map to capacity using the StyleSet's layout capacity // which accounts for the load factor { page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); defer page.assertIntegrity();
const max_items = page.styles.layout.cap; var n: usize = 1; while (n < max_items) : (n += 1) { _ = page.styles.add( page.memory, .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, ) catch break; } }
// Track the node before setAttribute const node_before_set = s.cursor.page_pin.node;
// Now try to set a new unique attribute that would require a new style slot // At max capacity, increaseCapacity will return OutOfSpace, triggering page split try s.setAttribute(.bold);
// The style should have been applied (bold flag set) try testing.expect(s.cursor.style.flags.bold);
// The cursor should have a valid non-default style_id try testing.expect(s.cursor.style_id != style.default_id);
// The page should have been split const page_was_split = s.cursor.page_pin.node != node_before_set or node_before_set.next != null or node_before_set.prev != null or s.cursor.page_pin.node != original_node; try testing.expect(page_was_split);}
test "selectionString map allocation failure cleanup" { // This test verifies that if toOwnedSlice fails when building // the StringMap, we don't leak the already-allocated map.string. const testing = std.testing; const alloc = testing.allocator; var s = try Screen.init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
try s.testWriteString("hello");
// Get a selection const sel = Selection.init( s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?, false, );
// Trigger allocation failure on toOwnedSlice var map: StringMap = undefined; selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); const result = s.selectionString(alloc, .{ .sel = sel, .map = &map, }); try testing.expectError(error.OutOfMemory, result); try selectionString_tw.end(.reset);
// If this test passes without memory leaks (when run with testing.allocator), // it means the errdefer properly cleaned up map.string when toOwnedSlice failed.}
test "Screen: promptClickMove line right basic" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor back to start of input (column 2, the 'h') s.cursorAbsolute(2, 0);
// Click on first 'l' (column 4), should require 2 right movements (h->e->l) const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 2), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove line right cursor not on input" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello"); s.cursorSetSemanticContent(.output);
// Move cursor back to prompt area (column 0, the '>') s.cursorAbsolute(0, 0);
// Cursor is on prompt, not input - should return zero const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);}
test "Screen: promptClickMove line right click on same position" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor to column 4 s.cursorAbsolute(4, 0);
// Click on same position - no movement needed const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);}
test "Screen: promptClickMove line right skips non-input cells" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write: "> h" then output "X" then input "llo" s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("h"); s.cursorSetSemanticContent(.output); try s.testWriteString("X"); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("llo");
// Move cursor to column 2 (the 'h') s.cursorAbsolute(2, 0);
// Click on 'l' at column 5 - should skip the 'X' output cell // Movement: h (start) -> l (col 4) -> l (col 5) = 2 right movements const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 2), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove line right soft-wrapped line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input that wraps s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); // Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols try s.testWriteString("abcdefgh"); // Continue on next row (soft-wrapped) try s.testWriteString("ij");
// Verify soft wrap occurred { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("> abcdefgh\nij", contents); }
// Move cursor to column 2 (the 'a') s.cursorAbsolute(2, 0);
// Click on 'j' at column 1, row 1 - should count all input cells // Movement: a->b->c->d->e->f->g->h->i->j = 9 right movements const click_pin = s.pages.pin(.{ .active = .{ .x = 1, .y = 1 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 9), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove disabled when click is none" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Click mode is .none by default (disabled) try testing.expectEqual(Screen.SemanticPrompt.SemanticClick.none, s.semantic_prompt.click);
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor to start of input s.cursorAbsolute(2, 0);
// Click should return zero since click mode is disabled const click_pin = s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(PromptClickMove.zero, result);}
test "Screen: promptClickMove line right stops at hard wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write prompt and input on first line s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello"); // Hard wrap (newline) try s.testWriteString("\n"); try s.testWriteString("world");
// Move cursor to column 2 (the 'h') s.cursorAbsolute(2, 0);
// Click on 'w' at column 0, row 1 - but line mode stops at hard wrap // Should only move to end of first line: h->e->l->l->o = 4 movements const click_pin = s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?; const result = s.promptClickMove(click_pin);
// Should stop at end of first line, not cross hard wrap try testing.expectEqual(@as(usize, 5), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove line right stops at non-continuation row" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Row 0: PROMPT "> hello" s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello\n");
// Row 1: CONTINUATION "world" s.cursorSetSemanticContent(.{ .prompt = .continuation }); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("world\n");
// Row 2: NEW PROMPT "> again" s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("again");
// Verify content { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("> hello\nworld\n> again", contents); }
// Move cursor to 'w' at column 0, row 1 s.cursorAbsolute(0, 1);
// Click on 'a' at column 2, row 2 - but row 2 is a new prompt // Should stop at end of "world": w->o->r->l->d = 4 movements const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?; const result = s.promptClickMove(click_pin);
// Should stop at 'd' (end of world), not cross to new prompt try testing.expectEqual(@as(usize, 5), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove line left basic" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Cursor is at column 7 (after 'o'), move it to column 6 (the 'o') s.cursorAbsolute(6, 0);
// Click on 'h' (column 2), should require 4 left movements (o->l->l->e->h) const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 4), result.left); try testing.expectEqual(@as(usize, 0), result.right);}
test "Screen: promptClickMove line left skips non-input cells" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write: "> h" then output "X" then input "llo" s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("h"); s.cursorSetSemanticContent(.output); try s.testWriteString("X"); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("llo");
// Move cursor to column 6 (the 'o') s.cursorAbsolute(6, 0);
// Click on 'h' at column 2 - should skip the 'X' output cell // Movement: o->l->l->h = 3 left movements (skipping X) const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 3), result.left); try testing.expectEqual(@as(usize, 0), result.right);}
test "Screen: promptClickMove line left soft-wrapped line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input that wraps s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); // Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols try s.testWriteString("abcdefgh"); // Continue on next row (soft-wrapped) try s.testWriteString("ij");
// Verify soft wrap occurred { const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); defer alloc.free(contents); try testing.expectEqualStrings("> abcdefgh\nij", contents); }
// Cursor is at column 2, row 1 (after 'j'). Move to 'j' at column 1. s.cursorAbsolute(1, 1);
// Click on 'a' at column 2, row 0 - should count all input cells backwards // Movement: j->i->h->g->f->e->d->c->b->a = 9 left movements const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 9), result.left); try testing.expectEqual(@as(usize, 0), result.right);}
test "Screen: promptClickMove line left stops at hard wrap" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write prompt and input on first line, then hard wrap s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello"); // Hard wrap (newline) try s.testWriteString("\n"); try s.testWriteString("world");
// Move cursor to 'd' at column 4, row 1 (an actual input cell) s.cursorAbsolute(4, 1);
// Click on 'h' at column 2, row 0 - but line mode stops at hard wrap // Should only move to start of second line, not cross to row 0 const click_pin = s.pages.pin(.{ .active = .{ .x = 2, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
// Should stop at start of second line: d->l->r->o->w = 4 movements try testing.expectEqual(@as(usize, 4), result.left); try testing.expectEqual(@as(usize, 0), result.right);}
test "Screen: promptClickMove click right of input same line" { const testing = std.testing; const alloc = testing.allocator;
// Set up: "> hello" where "> " is prompt and "hello" is input // Clicking to the right of the 'o' should move cursor past the input
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor to start of input (column 2, the 'h') s.cursorAbsolute(2, 0);
// Click beyond the input (column 15) - should move to one past the 'o' const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 5), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove click right of input cursor at end" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Cursor is already at column 7 (one past 'o') after writing // Click beyond the input (column 15) - no movement needed since // cursor is already at the end position const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 0), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove click right of input on lower line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor to start of input (column 2, the 'h') s.cursorAbsolute(2, 0);
// Click on a lower line (row 1) - should move to end of input // This is outside the prompt area so should clamp to end const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 1 } }).?; const result = s.promptClickMove(click_pin);
// From 'h', we need to pass e, l, l, o (4 cells) + 1 past end = 5 try testing.expectEqual(@as(usize, 5), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove click right of input cursor at end lower line" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Cursor is at column 7 after writing (one past 'o') // Click on a lower line (row 1) - cursor already at end, no movement needed const click_pin = s.pages.pin(.{ .active = .{ .x = 5, .y = 1 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 0), result.right); try testing.expectEqual(@as(usize, 0), result.left);}
test "Screen: promptClickMove click right of input cursor on last char" { const testing = std.testing; const alloc = testing.allocator;
var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); defer s.deinit();
// Enable line click mode s.semantic_prompt.click = .{ .cl = .line };
// Write a prompt and input s.cursorSetSemanticContent(.{ .prompt = .initial }); try s.testWriteString("> "); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); try s.testWriteString("hello");
// Move cursor to last input char (column 6, the 'o') s.cursorAbsolute(6, 0);
// Click beyond the input (column 15) const click_pin = s.pages.pin(.{ .active = .{ .x = 15, .y = 0 } }).?; const result = s.promptClickMove(click_pin);
try testing.expectEqual(@as(usize, 1), result.right); try testing.expectEqual(@as(usize, 0), result.left);}