Yet Another Markdown Editor! TUI for READMEs and friends.
editor markdown readme development-tool
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477use std::io;use std::sync::Arc;use std::time::Duration;
use crossterm::{ event::{self, Event, KeyCode, KeyModifiers, MouseButton, MouseEventKind}, execute, terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate},};use ratatui::{Terminal, layout::Rect, style::Style, widgets::Paragraph};use tui_textarea::CursorMove;
use yame::app::{App, FileMode, get_selection_text, resolve_file_mode};use yame::config::{LayoutConfig, Theme, load_config, write_config};use yame::decoration::{ DecorationMap, block_highlights_to_decoration_map, build_decoration_map, count_words_plain,};use yame::highlighting::HighlightCache;use yame::layout::{DEFAULT_MIN_COLS, compute_layout};use yame::renderer;use yame::search::SearchState;use yame::settings::{SettingsModal, SettingsOutcome, handle_settings_key};use yame::status::StatusMode;
use super::commands::{center_scroll, clamp_scroll, handle_exit, handle_save, handle_save_to_path};use super::picker;
// ---------------------------------------------------------------------------// Key-event outcome// ---------------------------------------------------------------------------
/// Signals the event loop needs to act on after `handle_key_event` returns.////// Keeping I/O (file saves, config reloads) out of `handle_key_event` makes/// that function fully unit-testable without a real terminal or filesystem.#[derive(Debug, PartialEq)]pub(super) enum KeyOutcome { /// Normal dispatch — state mutation complete, keep running. Continue, /// Ctrl+S / Super+S: persist buffer to disk, then keep running. Save, /// ExitPrompt Y: persist buffer to disk, then exit the loop. SaveAndExit, /// ExitPrompt N / Ctrl+Q on a clean buffer: exit without saving. Exit, /// Ctrl+R: reload config from disk and redisplay a confirmation banner. ReloadConfig, /// User confirmed a filename in the save-as prompt. Carries the typed path /// and whether to exit after saving (triggered by the exit-prompt Y path). SaveAs { filename: String, then_exit: bool }, /// Ctrl+O: suspend TUI, run the file picker, load the selected file. OpenPicker, /// F2: open the settings modal, loading current config from disk. OpenSettings, /// Settings field committed — write config to disk and apply it. CommitSettings,}
// ---------------------------------------------------------------------------// Background decoration worker// ---------------------------------------------------------------------------
/// Parameters shipped to the decoration worker for one decoration pass.////// All fields are cheaply cloneable or `Arc`-wrapped so the main thread can/// keep its own copies while the worker operates on its snapshot.struct DecorateRequest { text: String, mode: FileMode, theme: Theme, italic_support: bool, cache: Option<Arc<HighlightCache>>,}
/// Run the appropriate decoration pass for the given request.fn run_decorate(req: &DecorateRequest) -> (DecorationMap, usize) { match &req.mode { FileMode::Markdown => build_decoration_map( &req.text, &req.theme, req.italic_support, req.cache.as_deref(), ), FileMode::PlainHighlight(lang) => { let map = req .cache .as_deref() .and_then(|cache| cache.highlight_block(lang, &req.text)) .map(|hl| block_highlights_to_decoration_map(&hl, 0)) .unwrap_or_default(); let wc = count_words_plain(&req.text); (map, wc) } FileMode::PlainText => (DecorationMap::default(), count_words_plain(&req.text)), }}
// ---------------------------------------------------------------------------// Pure helpers// ---------------------------------------------------------------------------
/// Map a screen-absolute (row, col) mouse position to a logical document/// (row, col) position, accounting for the editor gutter, scroll offset, and/// soft-wrapped lines. Returns `None` if the click is outside the editor area.////// `decoration_map` is required to look up each line's `continuation_indent`/// so that list items and blockquotes (whose continuation rows are wrapped at a/// narrower width and rendered with a visual indent) count the correct number of/// visual rows and map column positions correctly.// Timeouts under cargo-mutants because the 91 binary-crate tests run in parallel// and a slow integration test reliably pushes the suite past the budget even when// a mutation-specific unit test would fail immediately. The function is already// covered by 10+ targeted unit tests (lines 930–1164) with inline mutation notes;// mutation verification here adds no safety signal beyond those tests.#[mutants::skip]pub(super) fn screen_to_doc( screen_row: u16, screen_col: u16, editor_area: &Rect, scroll_top: usize, lines: &[String], decoration_map: &yame::decoration::DecorationMap, show_line_numbers: bool,) -> Option<(u16, u16)> { if screen_row < editor_area.y || screen_col < editor_area.x || screen_row >= editor_area.y + editor_area.height || screen_col >= editor_area.x + editor_area.width { return None; } let left_gutter = renderer::left_gutter_width(lines.len(), show_line_numbers); let cw = (editor_area.width as usize) .saturating_sub(left_gutter as usize + renderer::GUTTER as usize) .max(1); let click_vis_row = (screen_row - editor_area.y) as usize; let click_col = screen_col.saturating_sub(editor_area.x + left_gutter) as usize;
let mut vis = 0usize; for (li, line) in lines.iter().enumerate().skip(scroll_top) { // Continuation indent for this line (0 for plain paragraphs, ≥2 for // list items and blockquotes). Must match the renderer exactly so that // visual row counts are identical. let line_ci = decoration_map .get(&li) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let cont_width = cw.saturating_sub(line_ci).max(1); let wrapped = renderer::wrap_line_indented(line, cw, cont_width); let seg_count = wrapped.len().max(1); if vis + seg_count > click_vis_row { let si = click_vis_row - vis; let char_ranges = renderer::wrap_char_ranges(line, &wrapped); let seg_char_start = char_ranges.get(si).map_or(0, |&(start, _)| start); let row_str = wrapped.get(si).copied().unwrap_or(""); // Continuation rows (si > 0) are rendered with `line_ci` visual // columns of indent before the text. Subtract that offset so the // click column maps to the correct position within `row_str`. let col_in_row = if si > 0 { click_col.saturating_sub(line_ci) } else { click_col }; let chars_into_row = renderer::chars_for_display_cols(row_str, col_in_row); let doc_col = (seg_char_start + chars_into_row).min(line.chars().count()); return Some((li as u16, doc_col as u16)); } vis += seg_count; } // Click fell below all rendered lines — land at the end of the last logical // line so that clicking below-and-right of the text behaves like clicking past // the end of the last line, not the beginning of it. let last = lines.len().saturating_sub(1); let end_col = lines.get(last).map_or(0, |l| l.chars().count()); Some((last as u16, end_col as u16))}
/// Returns `true` if the key is a pure cursor-movement key that cannot change/// document content. Used to skip the decoration debounce timer on nav presses.pub(super) fn is_navigation_key(k: &crossterm::event::KeyEvent) -> bool { // Ctrl+Up/Down are handled in their own explicit arm in handle_key_event // (they scroll the viewport rather than edit), so they never reach the `_` // arm where is_navigation_key is called. Nonetheless, matching purely on // k.code (ignoring modifiers) means Ctrl+Up is still classified nav here, // which is the correct policy if the arm ordering ever changes. matches!( k.code, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown )}
/// If there is an active selection and `k` is a pair-opener, wrap the/// selection with the corresponding pair and return `true`./// Returns `true` for key events that are safe to process in read-only mode:/// navigation, selection, copy, search, mode toggles, viewport scroll, and exit./// Everything else (character input, backspace, paste, undo/redo, save, …) returns/// `false` and will be suppressed with a status-bar reminder.pub(super) fn is_ro_allowed(mods: KeyModifiers, code: KeyCode) -> bool { matches!( (mods, code), // ── Navigation & selection ─────────────────────────────────────────── ( _, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right | KeyCode::Home | KeyCode::End | KeyCode::PageUp | KeyCode::PageDown, ) // ── Exit ───────────────────────────────────────────────────────────── | (KeyModifiers::NONE, KeyCode::Esc) | (KeyModifiers::CONTROL, KeyCode::Char('q')) // ── Copy (not paste) ───────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('c')) | (KeyModifiers::SUPER, KeyCode::Char('c')) // ── Search (read-only — replace is blocked inside handle_search_key) ─ | (KeyModifiers::CONTROL, KeyCode::Char('f')) // ── Go-to-line ─────────────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('g')) // ── Config reload ──────────────────────────────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('r')) // ── Mode toggles (typewriter, focus, read-only) ────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('t')) | (KeyModifiers::CONTROL, KeyCode::Char('d')) | (KeyModifiers::CONTROL, KeyCode::Char('e')) // ── Select all (non-destructive selection) ─────────────────────────── | (KeyModifiers::CONTROL, KeyCode::Char('a')) | (KeyModifiers::SUPER, KeyCode::Char('a')) // ── Shortcuts / settings modals ──────────────────────────────────────── | (KeyModifiers::NONE, KeyCode::F(1)) | (KeyModifiers::NONE, KeyCode::F(2)) )}
pub(super) fn handle_pair_wrap(app: &mut App, k: crossterm::event::KeyEvent) -> bool { if k.modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { return false; } let (open, close) = match k.code { KeyCode::Char('(') => ('(', ')'), KeyCode::Char('[') => ('[', ']'), KeyCode::Char('{') => ('{', '}'), KeyCode::Char('"') => ('"', '"'), KeyCode::Char('\'') => ('\'', '\''), KeyCode::Char('`') => ('`', '`'), KeyCode::Char('*') => ('*', '*'), KeyCode::Char('_') => ('_', '_'), _ => return false, }; let selected = match get_selection_text(app) { Some(s) => s, None => return false, }; // Single insert_str while selection is active: delete selection (1 undo entry) + // insert wrapped text (1 undo entry) = 2 steps, down from 3 with input() + insert_str(). app.textarea.insert_str(format!("{open}{selected}{close}")); true}
/// When `app.auto_close_pairs` is enabled and there is no active selection,/// handle a key event as an auto-close pair action:////// - Asymmetric openers `( [ {` always insert the open+close pair and place/// the cursor between them (one `Back` step)./// - Asymmetric closers `) ] }` skip over the next char if it matches; otherwise/// return `false` so the caller falls through to normal `textarea.input`./// - Symmetric chars `" ' \` * _` skip over if the next char matches; otherwise/// insert the pair and place the cursor between them.////// Returns `true` when the event was fully handled, `false` when the caller/// should fall through to the normal `textarea.input` path.pub(super) fn handle_auto_close(app: &mut App, k: crossterm::event::KeyEvent) -> bool { if !app.auto_close_pairs { return false; } if k.modifiers .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) { return false; } if app.textarea.selection_range().is_some() { return false; } let KeyCode::Char(c) = k.code else { return false; };
let (row, col) = app.textarea.cursor(); let next_char = app .textarea .lines() .get(row) .and_then(|line| line.chars().nth(col));
match c { // Asymmetric openers: always insert pair. '(' => { app.textarea.insert_str("()"); app.textarea.move_cursor(CursorMove::Back); true } '[' => { app.textarea.insert_str("[]"); app.textarea.move_cursor(CursorMove::Back); true } '{' => { app.textarea.insert_str("{}"); app.textarea.move_cursor(CursorMove::Back); true } // Asymmetric closers: skip-over only. ')' => { if next_char == Some(')') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } ']' => { if next_char == Some(']') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } '}' => { if next_char == Some('}') { app.textarea.move_cursor(CursorMove::Forward); true } else { false } } // Symmetric chars: skip-over if next matches, else insert pair. '"' | '\'' | '`' | '*' | '_' => { if next_char == Some(c) { app.textarea.move_cursor(CursorMove::Forward); true } else { app.textarea.insert_str(format!("{c}{c}")); app.textarea.move_cursor(CursorMove::Back); true } } _ => false, }}
// ---------------------------------------------------------------------------// Search helpers (called from handle_search_key)// ---------------------------------------------------------------------------
/// Replace the current match with `search.replace`, then refresh matches.fn do_replace_current(app: &mut App) { let Some((ml, ms, me)) = app.search.as_ref().and_then(|s| s.current_match()) else { return; }; let replace_text = app .search .as_ref() .map(|s| s.replace.clone()) .unwrap_or_default(); let match_len = me - ms;
app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); for _ in 0..match_len { app.textarea.delete_next_char(); } app.textarea.insert_str(&replace_text); app.force_redecorate = true; app.recompute_dirty(); app.last_keystroke = Some(std::time::Instant::now());
let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); // current index now points to the next match (positions shifted after replacement). }}
/// Replace every match with `search.replace`, processing from bottom to top/// so earlier char offsets stay valid as later spans are overwritten.fn do_replace_all(app: &mut App) { let Some(search) = &app.search else { return }; if search.matches.is_empty() { return; } let matches = search.matches.clone(); let replace_text = search.replace.clone();
for &(ml, ms, me) in matches.iter().rev() { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); for _ in 0..(me - ms) { app.textarea.delete_next_char(); } app.textarea.insert_str(&replace_text); } app.force_redecorate = true; app.recompute_dirty(); app.last_keystroke = Some(std::time::Instant::now());
let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); }}
// ---------------------------------------------------------------------------// Search-mode key dispatcher// ---------------------------------------------------------------------------
/// Handle a key event while the search bar is active.////// Printable characters feed the active field. Action keys navigate matches/// or perform replacements. Ctrl+S still saves; Escape closes the bar.pub(super) fn handle_search_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { // Dismiss the shortcut cheatsheet on the first keypress inside search mode. if let Some(s) = &mut app.search { s.show_help = false; }
match (k.modifiers, k.code) { // Close search. (KeyModifiers::NONE, KeyCode::Esc) => { app.search = None; app.search_last_typed = None; }
// Save still works inside search mode. (KeyModifiers::CONTROL, KeyCode::Char('s')) | (KeyModifiers::SUPER, KeyCode::Char('s')) => { return KeyOutcome::Save; }
// Next match: Ctrl+F or Enter while search field is focused. // Enter while replace field is focused: replace current then advance. (KeyModifiers::CONTROL, KeyCode::Char('f')) | (KeyModifiers::NONE, KeyCode::Enter) => { let replace_enter = k.code == KeyCode::Enter && app .search .as_ref() .is_some_and(|s| !s.focus_search && s.show_replace); if replace_enter { do_replace_current(app); } else { if let Some(s) = &mut app.search { s.next_match(); } let jump = app.search.as_ref().and_then(|s| s.current_match()); if let Some((ml, ms, _)) = jump { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); } } }
// Prev match: Shift+Enter. (KeyModifiers::SHIFT, KeyCode::Enter) => { if let Some(s) = &mut app.search { s.prev_match(); } let jump = app.search.as_ref().and_then(|s| s.current_match()); if let Some((ml, ms, _)) = jump { app.textarea .move_cursor(CursorMove::Jump(ml as u16, ms as u16)); } }
// Delete last char of the active field. (KeyModifiers::NONE, KeyCode::Backspace) => { if let Some(s) = &mut app.search { let was_search = s.focus_search; s.pop_char(); if was_search { app.search_last_typed = Some(std::time::Instant::now()); } } }
// Toggle regex mode — takes effect immediately (not debounced). (KeyModifiers::ALT, KeyCode::Char('r')) => { let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.regex_mode = !s.regex_mode; s.update_matches(&lines); app.search_last_typed = None; } }
// Ctrl+H: toggle the replace row (blocked in read-only mode). (KeyModifiers::CONTROL, KeyCode::Char('h')) if !app.read_only => { if let Some(s) = &mut app.search { s.show_replace = !s.show_replace; s.focus_search = true; // return focus to search field } }
// Tab: switch focus between search / replace fields. (KeyModifiers::NONE, KeyCode::Tab) => { if let Some(s) = &mut app.search && s.show_replace { s.focus_search = !s.focus_search; } }
// Ctrl+A: replace all (only active when the replace row is visible). (KeyModifiers::CONTROL, KeyCode::Char('a')) if app.search.as_ref().is_some_and(|s| s.show_replace) => { do_replace_all(app); }
// Printable character with no control modifier → feed active field. _ if !has_ctrl_alt_super(k.modifiers) && matches!(k.code, KeyCode::Char(_)) => { if let KeyCode::Char(c) = k.code && let Some(s) = &mut app.search { let was_search = s.focus_search; s.push_char(c); if was_search { app.search_last_typed = Some(std::time::Instant::now()); } } }
// Swallow everything else so stray keys don't reach the editor. _ => {} }
KeyOutcome::Continue}
// ---------------------------------------------------------------------------// Go-to-line key dispatcher// ---------------------------------------------------------------------------
/// Handle a key event while the go-to-line prompt is active.////// Only digit characters are accepted into the input buffer; Backspace edits it;/// Enter commits the jump; Esc cancels without moving the cursor.pub(super) fn handle_goto_line_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) => { app.status.mode = StatusMode::Normal; }
(KeyModifiers::NONE, KeyCode::Enter) => { // Parse the buffered digits and jump, then always close the prompt. // Scroll clamping is handled automatically on the next frame because // `free_scroll` is always false after a key press. if let Some(raw) = app.status.goto_input() && let Ok(n) = raw.parse::<usize>() && n >= 1 { let max_line = app.textarea.lines().len().saturating_sub(1); let target = (n - 1).min(max_line); app.textarea.move_cursor(CursorMove::Jump(target as u16, 0)); } app.status.mode = StatusMode::Normal; }
(KeyModifiers::NONE, KeyCode::Backspace) => { app.status.goto_pop(); }
// Accept digits only; silently ignore everything else. (KeyModifiers::NONE, KeyCode::Char(c)) if c.is_ascii_digit() => { app.status.goto_push(c); }
_ => {} } KeyOutcome::Continue}
// ---------------------------------------------------------------------------// Save-as key dispatcher// ---------------------------------------------------------------------------
/// Handle a key event while the save-as prompt is active (untitled buffer).////// Printable characters are appended to the filename buffer; Backspace edits/// it; Enter confirms (emitting `KeyOutcome::SaveAs`); Esc cancels and returns/// to `Normal` mode without saving.pub(super) fn handle_save_as_key(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) => { app.status.mode = StatusMode::Normal; }
(KeyModifiers::NONE, KeyCode::Enter) => { if let Some((input, then_exit)) = app.status.save_as_state() { let filename = input.trim().to_string(); if filename.is_empty() { // Nothing typed — stay in the prompt. return KeyOutcome::Continue; } app.status.mode = StatusMode::Normal; return KeyOutcome::SaveAs { filename, then_exit, }; } }
(KeyModifiers::NONE, KeyCode::Backspace) => { app.status.save_as_pop(); }
// Accept any printable character. (KeyModifiers::NONE | KeyModifiers::SHIFT, KeyCode::Char(c)) => { app.status.save_as_push(c); }
_ => {} } KeyOutcome::Continue}
// ---------------------------------------------------------------------------// Key-event dispatcher (pure — no file I/O, no terminal I/O)// ---------------------------------------------------------------------------
/// Dispatch a single key event, mutating `app` state.////// Returns `true` when `mods` contains any of CONTROL, ALT, or SUPER.////// Extracted so that `#[mutants::skip]` can suppress the undetectable `| → ^`/// mutations: for distinct-bit flags `a | b | c == a ^ b ^ c`, so no test can/// distinguish them.#[mutants::skip]fn has_ctrl_alt_super(mods: KeyModifiers) -> bool { mods.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)}
/// Select every character in the buffer and return the total line count.fn select_all(app: &mut App) -> usize { let n = app.textarea.lines().len(); app.textarea.cancel_selection(); app.textarea.move_cursor(CursorMove::Top); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::Bottom); app.textarea.move_cursor(CursorMove::End); n}
/// Returns a [`KeyOutcome`] telling the caller what (if any) I/O action to/// perform next. File writes, config reloads, and loop termination are the/// responsibility of the caller (`event_loop`). This separation makes the/// entire key-dispatch path unit-testable without a real terminal or filesystem.pub(super) fn handle_key_event(app: &mut App, k: crossterm::event::KeyEvent) -> KeyOutcome { // Any key press re-engages cursor-clamping scroll. // Ctrl+Up/Down immediately override this below by setting free_scroll = true again. app.free_scroll = false;
// ── Exit-prompt mode ──────────────────────────────────────────────────── if matches!(app.status.mode, StatusMode::ExitPrompt) { // Guard the destructive y/n responses: Ctrl+Y must not trigger SaveAndExit // and Ctrl+N must not trigger Exit. Other modifier+char combos (Ctrl+C, // Ctrl+Q …) still pass through so they continue to act as cancel shortcuts. if has_ctrl_alt_super(k.modifiers) && matches!(k.code, KeyCode::Char('y') | KeyCode::Char('n')) { return KeyOutcome::Continue; } return match k.code { KeyCode::Char('y') | KeyCode::Char('Y') => { if app.file_path.is_none() { // Untitled buffer — prompt for a filename; exit after save. app.status.start_save_as(true); KeyOutcome::Continue } else { KeyOutcome::SaveAndExit } } KeyCode::Char('n') | KeyCode::Char('N') => KeyOutcome::Exit, KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { app.status.mode = StatusMode::Normal; KeyOutcome::Continue } _ => KeyOutcome::Continue, }; }
// ── Shortcuts modal — swallow all keys; Esc or F1 closes it ───────────── if app.show_shortcuts { match (k.modifiers, k.code) { (KeyModifiers::NONE, KeyCode::Esc) | (KeyModifiers::NONE, KeyCode::F(1)) => { app.show_shortcuts = false; } _ => {} } return KeyOutcome::Continue; }
// ── Settings modal — delegate to settings key handler ──────────────────── if app.settings.is_some() { use yame::settings::VISIBLE_FIELDS; let outcome = { let modal = app.settings.as_mut().unwrap(); handle_settings_key(modal, k, VISIBLE_FIELDS) }; return match outcome { SettingsOutcome::Continue => KeyOutcome::Continue, SettingsOutcome::Close => { app.settings = None; KeyOutcome::Continue } SettingsOutcome::Committed => KeyOutcome::CommitSettings, }; }
// ── Save-as mode — intercepts all keys while the prompt is open ───── if matches!(app.status.mode, StatusMode::SaveAs { .. }) { return handle_save_as_key(app, k); }
// ── Switch-file prompt — dirty-buffer confirmation for Ctrl+O ──────── if matches!(app.status.mode, StatusMode::SwitchFilePrompt) { return match k.code { KeyCode::Char('y') | KeyCode::Char('Y') => { app.status.mode = StatusMode::Normal; KeyOutcome::OpenPicker } KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc | KeyCode::Char('c') | KeyCode::Char('C') => { app.status.mode = StatusMode::Normal; KeyOutcome::Continue } _ => KeyOutcome::Continue, }; }
// ── Go-to-line mode — intercepts all keys while the prompt is open ──── if matches!(app.status.mode, StatusMode::GoToLine { .. }) { return handle_goto_line_key(app, k); }
// ── Search mode — intercepts all keys while the bar is open ──────────── if app.search.is_some() { return handle_search_key(app, k); }
// ── Read-only guard ───────────────────────────────────────────────────── if app.read_only && !is_ro_allowed(k.modifiers, k.code) { app.status .set_timed("Read-only — ^Q to quit", Duration::from_secs(2)); return KeyOutcome::Continue; }
// ── Normal editing mode ───────────────────────────────────────────────── match (k.modifiers, k.code) { (KeyModifiers::CONTROL, KeyCode::Char('s')) | (KeyModifiers::SUPER, KeyCode::Char('s')) => { if app.file_path.is_none() { // Untitled buffer — open the save-as prompt instead. app.status.start_save_as(false); KeyOutcome::Continue } else { KeyOutcome::Save } }
// Ctrl+O / Cmd+O: open file picker. If the current buffer is dirty, // ask the user to confirm discarding changes before suspending the TUI. (KeyModifiers::CONTROL, KeyCode::Char('o')) | (KeyModifiers::SUPER, KeyCode::Char('o')) => { if app.is_dirty { app.status.mode = StatusMode::SwitchFilePrompt; KeyOutcome::Continue } else { KeyOutcome::OpenPicker } }
// Open search bar. (KeyModifiers::CONTROL, KeyCode::Char('f')) => { let (cur_line, cur_col) = app.textarea.cursor(); let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = SearchState::new(false); s.update_matches(&lines); s.snap_to_cursor(cur_line, cur_col); app.search = Some(s); KeyOutcome::Continue }
// Open search-and-replace bar. (KeyModifiers::CONTROL, KeyCode::Char('h')) => { let (cur_line, cur_col) = app.textarea.cursor(); let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = SearchState::new(true); s.update_matches(&lines); s.snap_to_cursor(cur_line, cur_col); app.search = Some(s); KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('q')) | (KeyModifiers::NONE, KeyCode::Esc) => { if handle_exit(app) { KeyOutcome::Exit } else { KeyOutcome::Continue } }
(KeyModifiers::CONTROL, KeyCode::Char('a')) | (KeyModifiers::SUPER, KeyCode::Char('a')) => { let n = select_all(app); app.status.set_timed( yame::clipboard::lines_msg("Selected", n), Duration::from_secs(2), ); KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('c')) | (KeyModifiers::SUPER, KeyCode::Char('c')) => { yame::clipboard::handle_copy(app); KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('x')) | (KeyModifiers::SUPER, KeyCode::Char('x')) => { yame::clipboard::handle_cut(app); app.force_redecorate = true; app.recompute_dirty(); KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('v')) | (KeyModifiers::SUPER, KeyCode::Char('v')) => { yame::clipboard::handle_paste(app); app.force_redecorate = true; KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('z')) | (KeyModifiers::SUPER, KeyCode::Char('z')) => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.undo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue }
// Ctrl+Y / Cmd+Y / Cmd+Shift+Z (macOS convention) → redo. (KeyModifiers::CONTROL, KeyCode::Char('y')) | (KeyModifiers::SUPER, KeyCode::Char('y')) => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.redo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue }
// Cmd+Shift+Z → redo (macOS standard; SUPER|SHIFT can't be an or-pattern). (mods, KeyCode::Char('z')) if mods == KeyModifiers::SUPER | KeyModifiers::SHIFT => { app.status.dismiss(); app.config_warnings.clear(); app.textarea.redo(); app.force_redecorate = true; app.last_keystroke = Some(std::time::Instant::now()); app.recompute_dirty(); KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Char('g')) => { app.status.start_goto_line(); KeyOutcome::Continue }
// Ctrl+T: toggle typewriter mode (keep cursor line centred in viewport). (KeyModifiers::CONTROL, KeyCode::Char('t')) => { app.typewriter_mode = !app.typewriter_mode; KeyOutcome::Continue }
// Ctrl+D: toggle focus mode (dim lines outside the current paragraph). (KeyModifiers::CONTROL, KeyCode::Char('d')) => { app.focus_mode = !app.focus_mode; KeyOutcome::Continue }
// Ctrl+E: toggle read-only mode on/off without exiting. (KeyModifiers::CONTROL, KeyCode::Char('e')) => { app.read_only = !app.read_only; let msg = if app.read_only { "Read-only on — editing disabled" } else { "Editing enabled" }; app.status.set_timed(msg, Duration::from_secs(2)); KeyOutcome::Continue }
// F1: open the full keybindings reference modal. (KeyModifiers::NONE, KeyCode::F(1)) => { app.show_shortcuts = true; KeyOutcome::Continue }
// F2: open the settings modal. (KeyModifiers::NONE, KeyCode::F(2)) => KeyOutcome::OpenSettings,
(KeyModifiers::CONTROL, KeyCode::Char('r')) => KeyOutcome::ReloadConfig,
// Alt+T / Option+T: reflow the GFM table under the cursor to uniform column widths. // On macOS, most terminals (Ghostty, Terminal.app) send '†' (U+2020, DAGGER) when // Option+T is pressed instead of a proper Alt sequence. Both forms are caught here // so the binding works regardless of whether the terminal is configured to send Meta. (KeyModifiers::ALT, KeyCode::Char('t')) | (KeyModifiers::NONE, KeyCode::Char('†')) => { yame::table_format::handle_format_table(app); KeyOutcome::Continue }
// Ctrl+K: insert a fenced code block (```\n```) and position the cursor // right after the opening ``` so the language tag can be typed immediately. // // Note: Ctrl+I cannot be used — terminals send it as ASCII 0x09 (Tab). // Alt+` is unreliable on macOS because Option produces special characters // unless the terminal is configured to use it as Meta. Ctrl+K is a plain // letter combo that every terminal delivers reliably. (KeyModifiers::CONTROL, KeyCode::Char('k')) => { let (row, col) = app.textarea.cursor(); app.textarea.insert_str("```\n```"); app.textarea .move_cursor(CursorMove::Jump(row as u16, (col + 3) as u16)); app.force_redecorate = true; app.mark_keystroke(); KeyOutcome::Continue }
// Ctrl+Up/Down: scroll viewport without moving cursor. (KeyModifiers::CONTROL, KeyCode::Up) => { app.scroll_top = app.scroll_top.saturating_sub(1); app.free_scroll = true; KeyOutcome::Continue }
(KeyModifiers::CONTROL, KeyCode::Down) => { let max = app.textarea.lines().len().saturating_sub(1); app.scroll_top = (app.scroll_top + 1).min(max); app.free_scroll = true; KeyOutcome::Continue }
// Visual-line Up/Down: step by displayed row, not by logical line. (KeyModifiers::NONE, KeyCode::Down) => handle_visual_move(app, true, false), (KeyModifiers::NONE, KeyCode::Up) => handle_visual_move(app, false, false), (KeyModifiers::SHIFT, KeyCode::Down) => handle_visual_move(app, true, true), (KeyModifiers::SHIFT, KeyCode::Up) => handle_visual_move(app, false, true),
// Tab: expand to spaces aligned to the next tab stop rather than inserting // a raw '\t'. Raw tabs break cursor positioning, selection, and soft-wrap // because the layout engine assumes every character is 1 display column wide. (KeyModifiers::NONE, KeyCode::Tab) => { let (_, col) = app.textarea.cursor(); let tw = app.tab_width; let spaces = tw - (col % tw); for _ in 0..spaces { app.textarea.input(crossterm::event::KeyEvent::new( KeyCode::Char(' '), KeyModifiers::NONE, )); } app.mark_keystroke(); KeyOutcome::Continue }
(KeyModifiers::NONE, KeyCode::Enter) => { app.sticky_col = None; app.status.dismiss(); app.config_warnings.clear(); handle_enter(app) }
_ => { // Any non-vertical-nav key ends the sticky-column gesture. app.sticky_col = None; app.status.dismiss(); app.config_warnings.clear(); let is_nav = is_navigation_key(&k);
if !is_nav && (handle_pair_wrap(app, k) || handle_auto_close(app, k)) { app.force_redecorate = true; app.mark_keystroke(); } else { let prev_line_count = app.textarea.lines().len(); app.textarea.input(k); if app.textarea.lines().len() != prev_line_count { app.force_redecorate = true; } if !is_nav { app.mark_keystroke(); } // Navigation keys cannot mutate content, so is_dirty cannot change; // skipping recompute_dirty() here avoids an O(N) line comparison on // every arrow-key press for large documents. } KeyOutcome::Continue } }}
// ---------------------------------------------------------------------------// List continuation// ---------------------------------------------------------------------------
/// Detect the list item prefix at the start of `line`.////// Returns `(current_prefix, next_prefix)` where `current_prefix` is the/// exact prefix characters on this line (e.g., `"- "`, `" 1. "`, `"- [ ] "`)/// and `next_prefix` is the prefix to insert on the continuation line (same/// for unordered; incremented number for ordered; always unchecked for tasks).////// Returns `None` if the line does not start with a list item marker.pub(super) fn detect_list_prefix(line: &str) -> Option<(String, String)> { let indent_len = line.len() - line.trim_start_matches(' ').len(); let indent = &line[..indent_len]; let rest = &line[indent_len..];
// Task list (check before plain unordered to avoid `- [ ]` matching as `- `). // Pattern: `[-*+] [ ] `, `[-*+] [x] `, `[-*+] [X] ` if let Some(bullet) = rest.chars().next().filter(|c| matches!(c, '-' | '*' | '+')) { let after_bullet = &rest[1..]; // all are ASCII (len_utf8 == 1) for mark in &[" [ ] ", " [x] ", " [X] "] { if after_bullet.starts_with(mark) { let current = format!("{indent}{bullet}{mark}"); let next = format!("{indent}{bullet} [ ] "); return Some((current, next)); } } // Plain unordered. if after_bullet.starts_with(' ') { let prefix = format!("{indent}{bullet} "); return Some((prefix.clone(), prefix)); } }
// Ordered list: one or more digits, then '.' or ')', then a space. let digit_end = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(0); if digit_end > 0 { let digits = &rest[..digit_end]; let after_digits = &rest[digit_end..]; if let Some(punct) = after_digits .chars() .next() .filter(|p| matches!(p, '.' | ')') && after_digits[1..].starts_with(' ')) { let num: u64 = digits.parse().unwrap_or(1); let current = format!("{indent}{digits}{punct} "); let next = format!("{indent}{}{punct} ", num + 1); return Some((current, next)); } }
None}
/// Handle the Enter key in normal editing mode, with list continuation.////// - If the cursor is on a list item whose content after the prefix is empty,/// the prefix is stripped from the current line (exit list)./// - If the cursor is on a list item with content, a newline is inserted/// followed by the continuation prefix (ordered numbers increment)./// - Otherwise falls through to a plain newline.fn handle_enter(app: &mut App) -> KeyOutcome { // Don't intercept Enter while a selection is active — let tui-textarea // handle the delete-selection-then-newline sequence normally. if app.textarea.selection_range().is_some() { app.textarea.insert_newline(); app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; }
let (row, _col) = app.textarea.cursor(); let line = app .textarea .lines() .get(row) .map(|s| s.as_str()) .unwrap_or("") .to_owned();
if let Some((current_prefix, next_prefix)) = detect_list_prefix(&line) { let content_after = &line[current_prefix.len()..]; if content_after.trim().is_empty() { // Empty list item → exit list: remove the prefix, leave a blank line. let prefix_char_count = current_prefix.chars().count(); app.textarea .move_cursor(CursorMove::Jump(row as u16, prefix_char_count as u16)); app.textarea.delete_line_by_head(); } else { // List item with content → insert newline then continuation prefix. app.textarea.insert_newline(); app.textarea.insert_str(&next_prefix); } app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; }
// Plain Enter — inherit leading whitespace from the current line, then newline. let indent: String = line .chars() .take_while(|c| *c == ' ' || *c == '\t') .collect();
// "Exit indent" — Enter on a pure-whitespace line strips one indent level // instead of producing another indented blank line (mirrors list exit). if !indent.is_empty() && indent.len() == line.len() { let strip = if indent.starts_with('\t') { 1 } else { app.tab_width.min(indent.len()) }; app.textarea .move_cursor(CursorMove::Jump(row as u16, strip as u16)); app.textarea.delete_line_by_head(); app.force_redecorate = true; app.mark_keystroke(); return KeyOutcome::Continue; }
let prev_line_count = app.textarea.lines().len(); app.textarea.insert_newline(); if app.textarea.lines().len() != prev_line_count { app.force_redecorate = true; } if !indent.is_empty() { app.textarea.insert_str(&indent); } app.mark_keystroke(); KeyOutcome::Continue}
// ---------------------------------------------------------------------------// Visual-line navigation// ---------------------------------------------------------------------------
/// Move the cursor one visual row up (`go_down = false`) or down (`go_down =/// true`), honouring soft-wrap so the cursor steps through displayed rows/// rather than jumping over wrapped text to the next logical line.////// Uses `app.content_width` (kept current by the event loop) and/// `app.decoration_map` (for continuation-indent widths on list/blockquote/// lines) to use exactly the same wrapping as the renderer.////// `app.sticky_col` is set on the first call of a vertical gesture and/// preserved on subsequent Up/Down presses; any other key clears it (see the/// `_` arm of `handle_key_event`).fn handle_visual_move(app: &mut App, go_down: bool, selecting: bool) -> KeyOutcome { let cw = app.content_width; if cw == 0 { // Geometry not yet known (before first render); fall back to native. let code = if go_down { KeyCode::Down } else { KeyCode::Up }; let mods = if selecting { KeyModifiers::SHIFT } else { KeyModifiers::NONE }; app.textarea .input(crossterm::event::KeyEvent::new(code, mods)); return KeyOutcome::Continue; }
let (cur_row, cur_col) = app.textarea.cursor(); let lines = app.textarea.lines();
// Wrap widths for the current logical line (matches renderer). let cur_ci = app .decoration_map .get(&cur_row) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let cur_cont = cw.saturating_sub(cur_ci).max(1); let cur_line = lines.get(cur_row).map_or("", |s| s.as_str());
let (cur_subrow, _cur_char_start, cur_total) = renderer::cursor_subrow_info(cur_line, cur_col, cw, cur_cont);
// Establish (or recover) the sticky column for this gesture. // Stored in display columns (not char count) so wide chars are handled correctly. let vcol = *app .sticky_col .get_or_insert_with(|| renderer::cursor_vcol(cur_line, cur_col, cw, cur_cont));
// Determine target (logical row, subrow-within-that-row). let (tgt_row, tgt_subrow) = if go_down { if cur_subrow + 1 < cur_total { (cur_row, cur_subrow + 1) } else if cur_row + 1 < lines.len() { (cur_row + 1, 0) } else { return KeyOutcome::Continue; // already at last visual row } } else { if cur_subrow > 0 { (cur_row, cur_subrow - 1) } else if cur_row > 0 { let prev = cur_row - 1; let prev_ci = app .decoration_map .get(&prev) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let prev_cont = cw.saturating_sub(prev_ci).max(1); let prev_line = lines.get(prev).map_or("", |s| s.as_str()); let prev_total = renderer::wrap_line_indented(prev_line, cw, prev_cont) .len() .max(1); // Equivalent-mutant note: `- → +` yields prev_total + 1 and `- → /` // yields prev_total / 1 = prev_total. Both are clamped to the last // valid subrow index by `char_col_at_visual` (which does // `target_subrow.min(last_idx)`), so no test can distinguish them from // `prev_total - 1`. (prev, prev_total - 1) } else { return KeyOutcome::Continue; // already at first visual row } };
// Convert (tgt_subrow, vcol) → logical char column in the target line. let tgt_line = lines.get(tgt_row).map_or("", |s| s.as_str()); let tgt_ci = app .decoration_map .get(&tgt_row) .map(|decs| { decs.iter() .map(|s| s.continuation_indent) .max() .unwrap_or(0) }) .unwrap_or(0) as usize; let tgt_cont = cw.saturating_sub(tgt_ci).max(1); let tgt_col = renderer::char_col_at_visual(tgt_line, tgt_subrow, vcol, cw, tgt_cont);
// Apply or extend selection. if selecting { if app.textarea.selection_range().is_none() { app.textarea.start_selection(); } } else { app.textarea.cancel_selection(); }
app.textarea .move_cursor(CursorMove::Jump(tgt_row as u16, tgt_col as u16)); KeyOutcome::Continue}
// ---------------------------------------------------------------------------// Event loop// ---------------------------------------------------------------------------
/// Apply a committed settings change: write config to disk, rebuild theme, and send a fresh/// decoration request. Shared by the keyboard CommitSettings path and mouse click activation.#[mutants::skip] // Performs disk I/O (write_config) and terminal-state mutation.fn do_commit_settings(app: &mut App, deco_tx: &std::sync::mpsc::Sender<DecorateRequest>) { let (cfg, new_tw, new_fm, new_ro) = match &app.settings { Some(m) => ( m.config.clone(), m.typewriter_mode, m.focus_mode, m.read_only, ), None => return, }; match write_config(&cfg) { Ok(()) => { let mut warnings = Vec::new(); app.theme = Theme::from_config(&cfg.palette, &cfg.theme, &cfg.headings, &mut warnings); app.highlight_cache = cfg.highlighting.enabled.then(|| { let palette_theme = cfg .highlighting .use_palette_colors .then(|| yame::highlighting::build_palette_theme(&app.theme)); Arc::new(yame::highlighting::HighlightCache::new( true, cfg.highlighting.syntect_theme.clone(), palette_theme, )) }); if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode(p, &cfg.filetype); } app.show_line_numbers = cfg.layout.line_numbers.unwrap_or(false); app.powerline_glyphs = cfg.layout.powerline_glyphs.unwrap_or(true); app.tab_width = cfg.layout.tab_width.unwrap_or(4) as usize; app.auto_close_pairs = cfg.layout.auto_close_pairs.unwrap_or(false); app.autosave_secs = cfg.layout.autosave_seconds.map(|n| n as u64).unwrap_or(0); app.typewriter_mode = new_tw; app.focus_mode = new_fm; app.read_only = new_ro; app.config_warnings = warnings; // Send a fresh decoration request immediately so the new theme is applied without // waiting for the DEBOUNCE timer. We do NOT clear decoration_map or drain deco_rx // — the worker's own "latest wins" drain handles rapid cycling, and clearing would // cause a visible flash to unstyled text. let _ = deco_tx.send(DecorateRequest { text: app.textarea.lines().join("\n"), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), }); // Refresh the modal's resolved theme so derived defaults update immediately. if let Some(modal) = app.settings.as_mut() { modal.resolved = app.theme.clone(); } } Err(e) => { app.status .set_timed(format!("⚠ Settings error: {e}"), Duration::from_secs(3)); } }}
#[mutants::skip] // Terminal I/O loop — requires a real terminal backend + live event stream; not unit-testable.pub(super) fn event_loop<B: ratatui::backend::Backend>( terminal: &mut Terminal<B>, app: &mut App, layout_config: &LayoutConfig,) -> io::Result<()>where io::Error: From<B::Error>,{ const POLL_TIMEOUT: Duration = Duration::from_millis(16); const DEBOUNCE: Duration = Duration::from_millis(50); const SEARCH_DEBOUNCE: Duration = Duration::from_millis(100); const BOTTOM_PADDING: usize = 3; const SCROLL_LINES: usize = 3;
let min_cols = layout_config.min_cols.unwrap_or(DEFAULT_MIN_COLS); let max_cols = layout_config.max_cols;
// Spawn a persistent background decoration worker. // // The worker receives `DecorateRequest` messages and sends back // `(DecorationMap, usize)` results. Before starting each job it drains // any superseded requests ("latest wins") so rapid typing never queues up // stale work. The thread exits automatically when `deco_tx` is dropped // (i.e. when the event loop returns). let (deco_tx, deco_work_rx) = std::sync::mpsc::channel::<DecorateRequest>(); let (deco_result_tx, deco_rx) = std::sync::mpsc::channel::<(DecorationMap, usize)>(); std::thread::Builder::new() .name("deco-worker".into()) .spawn(move || { while let Ok(req) = deco_work_rx.recv() { // Drain any superseded requests — only the latest matters. let req = std::iter::once(req) .chain(deco_work_rx.try_iter()) .last() .unwrap(); // always at least one element let result = run_decorate(&req); if deco_result_tx.send(result).is_err() { break; // main thread gone, exit cleanly } } }) .expect("failed to spawn decoration worker");
// force_redecorate=true (set in App::new) triggers an immediate background // decoration request on the first loop iteration. Text is visible from the // first draw; decorations arrive within one or two frames.
let mut last_editor_area = Rect::default(); let mut drag_selecting = false; let mut last_autosave = std::time::Instant::now();
loop { // Apply the latest completed decoration result from the background worker. // Drain all pending results and keep only the freshest one. let latest = std::iter::from_fn(|| deco_rx.try_recv().ok()).last(); if let Some((map, wc)) = latest { app.decoration_map = map; app.word_count = wc; }
if app.force_redecorate || app.last_keystroke.is_some_and(|t| t.elapsed() >= DEBOUNCE) { let _ = deco_tx.send(DecorateRequest { text: app.textarea.lines().join("\n"), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), }); app.last_keystroke = None; app.force_redecorate = false; }
if app .search_last_typed .is_some_and(|t| t.elapsed() >= SEARCH_DEBOUNCE) { let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); if let Some(s) = &mut app.search { s.update_matches(&lines); } app.search_last_typed = None; }
// Autosave: fire when enabled, dirty, file known, and interval elapsed. if app.autosave_secs > 0 && app.is_dirty && app.file_path.is_some() && last_autosave.elapsed() >= Duration::from_secs(app.autosave_secs) { handle_save(app)?; last_autosave = std::time::Instant::now(); }
app.status.tick();
// Pre-draw scroll clamp { let term_size = terminal.size()?; let term_area = Rect::new(0, 0, term_size.width, term_size.height); let pre_layout = compute_layout(term_area, min_cols, max_cols); // Account for warning banner and search bar when computing the // editor area so scroll clamping uses the same geometry as the draw. let warn_rows = u16::from(!app.config_warnings.is_empty() && pre_layout.column.height > 0); let sb_rows = renderer::search_bar_height(app); let top_rows = warn_rows + sb_rows; let pre_editor_area = Rect { y: pre_layout.column.y + top_rows, height: pre_layout.column.height.saturating_sub(top_rows), ..pre_layout.column }; // Keep content_width current so handle_visual_move wraps identically // to the renderer. Computed here (pre-draw) so it is valid before // the first key event arrives. let left_gutter = renderer::left_gutter_width(app.textarea.lines().len(), app.show_line_numbers); app.content_width = (pre_editor_area.width as usize) .saturating_sub(left_gutter as usize + renderer::GUTTER as usize) .max(1);
// Clamp is skipped while the user is free-scrolling (mouse wheel or // Ctrl+Up/Down). free_scroll persists until a key press, mouse click, // drag, or terminal resize clears it (scroll and hover events do not). // In typewriter mode the cursor line is kept vertically centred instead // of merely kept in-viewport. Free-scroll still works; the view // re-centres on the next non-scroll event. if !app.free_scroll { if app.typewriter_mode { center_scroll(app, pre_editor_area); } else { clamp_scroll(app, pre_editor_area, BOTTOM_PADDING); } } }
execute!(io::stdout(), BeginSynchronizedUpdate)?; terminal.draw(|f| { let layout = compute_layout(f.area(), min_cols, max_cols);
let content_bg_area = Rect { x: layout.full.x, y: layout.full.y, width: layout.full.width, height: layout.column.height, }; f.render_widget( Paragraph::new("").style(Style::default().bg(app.theme.bg)), content_bg_area, );
// Warning banner (if any) — always the topmost row of the column. let after_warn = if !app.config_warnings.is_empty() && layout.column.height > 0 { let warn_area = Rect { height: 1, ..layout.column }; let msg = format!(" ⚠ {} [any key to dismiss]", app.config_warnings[0]); f.render_widget( Paragraph::new(msg) .style(Style::default().fg(app.theme.warning).bg(app.theme.ui_bar)), warn_area, ); Rect { y: layout.column.y + 1, height: layout.column.height.saturating_sub(1), ..layout.column } } else { layout.column };
// Search bar — rendered below any warning banner, above the editor. let sb_rows = renderer::search_bar_height(app); let editor_area = if sb_rows > 0 && after_warn.height > sb_rows { let sb_area = Rect { x: layout.full.x, y: after_warn.y, width: layout.full.width, height: sb_rows, }; renderer::render_search_bar(f, sb_area, app); Rect { y: after_warn.y + sb_rows, height: after_warn.height.saturating_sub(sb_rows), ..after_warn } } else { after_warn };
let (search_matches, search_current) = app .search .as_ref() .map(|s| (s.matches.as_slice(), s.current)) .unwrap_or((&[], 0)); let focus_mode_paragraph = if app.focus_mode { Some(renderer::focus_paragraph_bounds( app.textarea.lines(), app.textarea.cursor().0, )) } else { None }; let view = renderer::MarkdownView { lines: app.textarea.lines(), decoration_map: &app.decoration_map, scroll_top: app.scroll_top, cursor: app.textarea.cursor(), selection: app.textarea.selection_range(), theme: &app.theme, column_width: layout.column.width, show_line_numbers: app.show_line_numbers, search_matches, search_current, focus_mode_paragraph, }; f.render_widget(view, editor_area); if app.search.as_ref().is_some_and(|s| s.show_help) { renderer::render_search_help_modal(f, editor_area, app); } if app.show_shortcuts { renderer::render_shortcuts_modal(f, editor_area, app); } if app.settings.is_some() { renderer::render_settings_modal(f, editor_area, app); } renderer::render_status_bar(f, layout.status_bar, app); renderer::render_info_line(f, layout.info_line, app);
last_editor_area = editor_area; })?; execute!(io::stdout(), EndSynchronizedUpdate)?;
if event::poll(POLL_TIMEOUT)? { match event::read()? { // Only process key-press and key-repeat events. When the Kitty // keyboard protocol is active (e.g. leaked from a previous app // such as neovim that did not restore terminal state on exit), // the terminal also sends Release events. Letting those through // causes spurious characters — e.g. releasing Ctrl before Z after // a Ctrl+Z produces a bare `(NONE, Char('z'), Release)` that // bypasses the CONTROL match arm and inserts 'z'. Event::Key(k) if k.kind != crossterm::event::KeyEventKind::Release => { let prev_free_scroll = app.free_scroll; match handle_key_event(app, k) { KeyOutcome::Continue => {} KeyOutcome::Save => { // Save doesn't move the cursor, so keep the viewport where it was. app.free_scroll = prev_free_scroll; handle_save(app)?; last_autosave = std::time::Instant::now(); } KeyOutcome::SaveAndExit => { handle_save(app)?; break; } KeyOutcome::Exit => break, KeyOutcome::ReloadConfig => { let (new_config, new_warnings) = load_config(); let mut warnings = new_warnings; app.theme = Theme::from_config( &new_config.palette, &new_config.theme, &new_config.headings, &mut warnings, ); // Rebuild the highlight cache so fenced code blocks pick up // any theme or palette changes immediately. app.highlight_cache = new_config.highlighting.enabled.then(|| { let palette_theme = new_config .highlighting .use_palette_colors .then(|| yame::highlighting::build_palette_theme(&app.theme)); Arc::new(yame::highlighting::HighlightCache::new( true, new_config.highlighting.syntect_theme.clone(), palette_theme, )) }); // Re-resolve file mode in case [filetype] config changed. // Untitled buffers stay in Markdown mode until saved. if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode(p, &new_config.filetype); } app.show_line_numbers = new_config.layout.line_numbers.unwrap_or(false); app.auto_close_pairs = new_config.layout.auto_close_pairs.unwrap_or(false); app.autosave_secs = new_config .layout .autosave_seconds .map(|n| n as u64) .unwrap_or(0); app.config_warnings = warnings; app.status .set_timed("Config reloaded.", Duration::from_millis(1500)); app.last_keystroke = Some(std::time::Instant::now()); } KeyOutcome::SaveAs { filename, then_exit, } => { let path = std::path::PathBuf::from(&filename); app.shortened_path = renderer::shorten_path(&path, 3); app.file_mode = resolve_file_mode(&path, &yame::config::FiletypeConfig::default()); app.file_path = Some(path); handle_save_to_path(app)?; if then_exit { break; } } KeyOutcome::OpenSettings => { let (config, _) = load_config(); let mut warnings = Vec::new(); let resolved = Theme::from_config( &config.palette, &config.theme, &config.headings, &mut warnings, ); app.settings = Some(SettingsModal::new( config, resolved, app.typewriter_mode, app.focus_mode, app.read_only, )); } KeyOutcome::CommitSettings => { do_commit_settings(app, &deco_tx); } KeyOutcome::OpenPicker => { match picker::pick_file() { Ok(Some(path)) => { // No-op if the user picked the file already open. let same = app.file_path.as_deref() == Some(path.as_path()); if !same { match app.load_new_file(path, app.tab_width) { Ok(()) => { if let Some(p) = &app.file_path { app.file_mode = resolve_file_mode( p, &yame::config::FiletypeConfig::default(), ); } } Err(e) => { app.status .set_dismissible(format!("⚠ Open failed: {e}")); } } } } Ok(None) => {} // user cancelled Err(e) => { app.status.set_dismissible(format!("⚠ Picker: {e}")); } } // Always force a full repaint after leaving/re-entering // alternate screen — the terminal may have been dirtied. terminal.clear()?; } } } Event::Mouse(mouse) => { use yame::settings::{ModalHit, VISIBLE_FIELDS}; // Compute hit while the borrow of app.settings is scoped separately. let modal_hit = app .settings .as_ref() .map(|m| m.hit_test(mouse.row, mouse.column, last_editor_area)); match mouse.kind { MouseEventKind::ScrollDown => { // Scroll inside modal → move field cursor; outside → scroll editor. if matches!(modal_hit, Some(ModalHit::Field(_) | ModalHit::Tab(_))) { app.settings.as_mut().unwrap().move_down(VISIBLE_FIELDS); } else { let max = app.textarea.lines().len().saturating_sub(1); app.scroll_top = (app.scroll_top + SCROLL_LINES).min(max); app.free_scroll = true; } } MouseEventKind::ScrollUp => { if matches!(modal_hit, Some(ModalHit::Field(_) | ModalHit::Tab(_))) { app.settings.as_mut().unwrap().move_up(); } else { app.scroll_top = app.scroll_top.saturating_sub(SCROLL_LINES); app.free_scroll = true; } } MouseEventKind::Down(MouseButton::Left) => { use yame::settings::SettingsOutcome; match modal_hit { Some(ModalHit::Tab(t)) => { app.settings.as_mut().unwrap().switch_to_tab(t); } Some(ModalHit::Field(f)) => { // Navigate to the clicked field, then activate it if it's a // toggle, cycler, or expand row (text/color fields need Enter). let outcome = { let modal = app.settings.as_mut().unwrap(); modal.jump_to_field(f); modal.activate_clicked_field() }; if matches!(outcome, SettingsOutcome::Committed) { do_commit_settings(app, &deco_tx); } } Some(ModalHit::Outside) => { // Click truly outside the modal bounds — dismiss. app.settings = None; } Some(ModalHit::Inert) => { // Click on non-interactive interior surface (border, separator, // hint row) — keep the modal open, do nothing. } None => { // No modal open — standard click behaviour. app.free_scroll = false; drag_selecting = false; if let Some((doc_row, doc_col)) = screen_to_doc( mouse.row, mouse.column, &last_editor_area, app.scroll_top, app.textarea.lines(), &app.decoration_map, app.show_line_numbers, ) { app.textarea.cancel_selection(); app.textarea .move_cursor(CursorMove::Jump(doc_row, doc_col)); } } } } MouseEventKind::Drag(MouseButton::Left) if modal_hit.is_none() => { // Drag moves the cursor, so re-engage cursor-clamping scroll. app.free_scroll = false; if let Some((doc_row, doc_col)) = screen_to_doc( mouse.row, mouse.column, &last_editor_area, app.scroll_top, app.textarea.lines(), &app.decoration_map, app.show_line_numbers, ) { if !drag_selecting { app.textarea.start_selection(); drag_selecting = true; } app.textarea.move_cursor(CursorMove::Jump(doc_row, doc_col)); } } _ => {} } } Event::Paste(text) => { // Bracketed paste: the terminal has wrapped the pasted content // in escape sequences so crossterm delivers the entire payload // as a single event. Insert it directly — bypassing // handle_enter so list-continuation logic never fires on the // embedded newlines. app.textarea.insert_str(&text); app.force_redecorate = true; app.mark_keystroke(); } Event::Resize(_, _) => { // Viewport geometry changed — re-engage cursor-clamping scroll // so the cursor is guaranteed visible after the resize. app.free_scroll = false; } _ => { // Unknown events (FocusGained, FocusLost, mouse hover, …) do // NOT clear free_scroll — they are background events that should // not interrupt an explicit scroll the user initiated. } } } }
Ok(())}
// ---------------------------------------------------------------------------// Tests// ---------------------------------------------------------------------------
#[cfg(test)]mod tests { use super::*; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::path::PathBuf; use tui_textarea::TextArea; use yame::app::{App, ClipboardState, FileMode}; use yame::config::Theme; use yame::decoration::DecorationMap; use yame::status::StatusLine;
fn make_app() -> App { App { textarea: TextArea::default(), file_path: Some(PathBuf::from("test.md")), shortened_path: "test.md".to_string(), is_dirty: false, saved_content: None, theme: Theme::default_theme(), italic_support: false, powerline_glyphs: false, last_keystroke: None, force_redecorate: false, decoration_map: DecorationMap::default(), word_count: 0, status: StatusLine::default(), config_warnings: vec![], scroll_top: 0, free_scroll: false, sticky_col: None, content_width: 0, clipboard: ClipboardState::Uninitialized, tab_width: 4, highlight_cache: None, file_mode: FileMode::Markdown, show_line_numbers: false, search: None, search_last_typed: None, typewriter_mode: false, focus_mode: false, show_shortcuts: false, read_only: false, auto_close_pairs: false, autosave_secs: 0, settings: None, } }
fn key(code: KeyCode) -> KeyEvent { KeyEvent::new(code, KeyModifiers::NONE) }
// Kills: input.rs:269:24 replace && with || in handle_key_event. // With ||: !is_nav || handle_pair_wrap short-circuits to true for any non-nav key // (handle_pair_wrap is never evaluated), so the if-branch is entered without // calling textarea.input → the textarea stays empty instead of receiving 'a'. #[test] fn typing_char_reaches_textarea() { let mut app = make_app(); handle_key_event(&mut app, key(KeyCode::Char('a'))); assert_eq!( app.textarea.lines()[0], "a", "typed char must reach textarea" ); }
// Kills: input.rs:269:16 delete ! in handle_key_event. // Without !: is_nav && handle_pair_wrap short-circuits to false for all non-nav // keys (is_nav=false), so pair-wrap is never called; instead textarea.input('(') // is called → just "(" instead of the wrapped "(hello)". #[test] fn pair_wrap_with_selection_wraps() { let mut app = make_app(); app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!( app.textarea.lines()[0], "(hello)", "pair-wrap must wrap the selection" ); }
// Kills: the insert_str(open+selected+close) line being split back into two calls. // Two undo steps restore the buffer fully; a third undo would over-shoot into the // pre-"hello" state, proving the wrap is recorded as exactly 2 history entries. #[test] fn pair_wrap_undoes_in_two_steps() { let mut app = make_app(); app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "(hello)");
app.textarea.undo(); // remove "(hello)" insertion app.textarea.undo(); // restore "hello" by un-deleting the selection assert_eq!( app.textarea.lines()[0], "hello", "two undos must fully restore the pre-wrap state" );
// A third undo would overshoot into blank — verify it does nothing more. app.textarea.undo(); assert_eq!( app.textarea.lines()[0], "", "third undo removes the original 'hello' insert, not some phantom entry" ); }
// ── Auto-close pairs ─────────────────────────────────────────────────────
// Kills: `delete !app.auto_close_pairs` guard — with the guard removed, // typing `(` would insert `()` even when the feature is off. #[test] fn auto_close_off_typing_open_inserts_single_char() { let mut app = make_app(); // auto_close_pairs = false handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "("); }
// Kills: `delete insert_str("()")` branch — the opener would do nothing. #[test] fn auto_close_on_typing_open_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "()"); assert_eq!( app.textarea.cursor(), (0, 1), "cursor must sit between the pair" ); }
// Kills: `delete move_cursor(Back)` — cursor would land after the pair, not between. #[test] fn auto_close_on_cursor_placed_between_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); assert_eq!(app.textarea.cursor(), (0, 1)); }
// Kills: `delete move_cursor(Forward)` in skip-over branch — closer would be // inserted as a duplicate instead of consumed. #[test] fn auto_close_on_typing_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('('))); // cursor is at col 1 inside "()" handle_key_event(&mut app, key(KeyCode::Char(')'))); assert_eq!(app.textarea.lines()[0], "()", "no duplicate close inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); }
// Kills: `delete Some(c) ==` guard — symmetric char would always insert a pair // instead of skipping over the existing one. #[test] fn auto_close_on_symmetric_char_skips_over_existing() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('"'))); // cursor between the two quotes: `"|"` handle_key_event(&mut app, key(KeyCode::Char('"'))); assert_eq!(app.textarea.lines()[0], "\"\"", "no triple-quote produced"); assert_eq!(app.textarea.cursor(), (0, 2)); }
// Kills: `delete selection_range().is_some()` guard — with the guard removed, // auto_close would fire on top of pair_wrap (selection case). #[test] fn auto_close_on_with_selection_still_wraps() { let mut app = make_app(); app.auto_close_pairs = true; app.textarea.insert_str("hello"); app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(CursorMove::End); handle_key_event(&mut app, key(KeyCode::Char('('))); assert_eq!(app.textarea.lines()[0], "(hello)"); }
// Kills: input.rs:294:43 replace | with & in handle_auto_close. // With |→&, CONTROL & ALT = 0 (non-overlapping bits), so intersects(0) is always // false — Ctrl+( bypasses the modifier guard and auto-closes to "()". #[test] fn auto_close_ctrl_char_not_handled() { let mut app = make_app(); app.auto_close_pairs = true; let handled = handle_auto_close( &mut app, KeyEvent::new(KeyCode::Char('('), KeyModifiers::CONTROL), ); assert!(!handled, "Ctrl+( must not trigger auto-close"); assert_eq!( app.textarea.lines()[0], "", "no characters must be inserted" ); }
// Kills: input.rs:319:9 delete match arm '['. // With arm deleted [ falls to _ → returns false; caller inserts just "[" not "[]". #[test] fn auto_close_square_bracket_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); assert_eq!(app.textarea.lines()[0], "[]"); assert_eq!(app.textarea.cursor(), (0, 1)); }
// Kills: input.rs:324:9 delete match arm '{'. #[test] fn auto_close_curly_brace_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('{'))); assert_eq!(app.textarea.lines()[0], "{}"); assert_eq!(app.textarea.cursor(), (0, 1)); }
// Kills: input.rs:338:9 delete match arm ']' and 339:26 replace == with !=. // Arm deleted: ] falls to _ → inserts ], giving "[]" → "]" = "[]]". // ==→!=: next_char IS ] → condition false → inserts ], same "[]]". #[test] fn auto_close_square_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('['))); handle_key_event(&mut app, key(KeyCode::Char(']'))); assert_eq!(app.textarea.lines()[0], "[]", "no duplicate ] inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); }
// Kills: input.rs:346:9 delete match arm '}' and 347:26 replace == with !=. #[test] fn auto_close_curly_closer_skips_over() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('{'))); handle_key_event(&mut app, key(KeyCode::Char('}'))); assert_eq!(app.textarea.lines()[0], "{}", "no duplicate }} inserted"); assert_eq!( app.textarea.cursor(), (0, 2), "cursor must be past the closer" ); }
// Kills: input.rs:355:9 delete match arm '"' | '\'' | '`' | '*' | '_'. // With arm deleted, " falls to _ → false → caller inserts single " not "". // The existing skip-over test types " twice, so both mutations produce "" // at cursor col 2 — that test does NOT distinguish. This one types " once. #[test] fn auto_close_symmetric_char_inserts_pair() { let mut app = make_app(); app.auto_close_pairs = true; handle_key_event(&mut app, key(KeyCode::Char('"'))); assert_eq!( app.textarea.lines()[0], "\"\"", "symmetric char must insert pair" ); assert_eq!( app.textarea.cursor(), (0, 1), "cursor must sit between the pair" ); }
// Kills: input.rs:275:47 replace != with == in handle_key_event. // With ==: force_redecorate is set only when line count has NOT changed; // pressing Enter adds a new line, so with the mutation force_redecorate is NOT set. #[test] fn enter_sets_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event(&mut app, key(KeyCode::Enter)); assert!( app.force_redecorate, "Enter adds a line — force_redecorate must be true" ); }
// ── Visual-line navigation ───────────────────────────────────────────────
fn nav_app(lines: Vec<&str>, content_width: usize) -> App { let mut app = make_app(); app.content_width = content_width; app.textarea = TextArea::new(lines.into_iter().map(String::from).collect()); app }
// Down stays within the same logical line when it wraps. // "abcde fghij" at width 8 wraps: first row "abcde" (cols 0-4), // second row "fghij" (cols 6-10). Cursor at col 0 → Down → col 6. #[test] fn visual_down_within_wrapped_line() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (0, 6), "Down must land on second visual row of same logical line" ); }
// Up reverses the within-line move. #[test] fn visual_up_within_wrapped_line() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 6)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 0), "Up must return to first visual row of same logical line" ); }
// Down on the last visual row of line 0 crosses to line 1. #[test] fn visual_down_crosses_logical_line() { let mut app = nav_app(vec!["abc", "def"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 2)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (1, 2), "Down from last visual row must cross to next logical line" ); }
// Up on the first visual row of line 1 crosses back to line 0. #[test] fn visual_up_crosses_logical_line() { let mut app = nav_app(vec!["abc", "def"], 20); app.textarea.move_cursor(CursorMove::Jump(1, 2)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 2), "Up from first visual row must cross back to previous logical line" ); }
// Sticky col is set on the first Down and preserved on the second, so // moving through a short middle line restores the column on a longer line. // Lines: ["abcde", "ab", "abcde"], width 20 (no wrapping). // Cursor at (0, 4): Down → (1, 2) [clamped]; Down → (2, 4) [restored]. #[test] fn sticky_col_preserved_through_short_line() { let mut app = nav_app(vec!["abcde", "ab", "abcde"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 4)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!(app.textarea.cursor(), (1, 2), "clamped to short line"); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (2, 4), "sticky col must restore original column on longer line" ); }
// Any non-vertical-nav key must clear sticky_col. #[test] fn sticky_col_cleared_by_non_vertical_key() { let mut app = nav_app(vec!["abcde", "ab", "abcde"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 4)); handle_key_event(&mut app, key(KeyCode::Down)); // sets sticky_col = 4 assert!(app.sticky_col.is_some(), "sticky_col set after Down"); handle_key_event(&mut app, key(KeyCode::Right)); // non-vertical → clears assert!( app.sticky_col.is_none(), "sticky_col must be cleared by Right" ); }
// Down at the last line/row is a no-op (cursor stays put). #[test] fn visual_down_at_last_row_is_noop() { let mut app = nav_app(vec!["abc"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 1)); handle_key_event(&mut app, key(KeyCode::Down)); assert_eq!( app.textarea.cursor(), (0, 1), "Down at last row must not move cursor" ); }
// Up at the first row is a no-op. #[test] fn visual_up_at_first_row_is_noop() { let mut app = nav_app(vec!["abc"], 20); app.textarea.move_cursor(CursorMove::Jump(0, 1)); handle_key_event(&mut app, key(KeyCode::Up)); assert_eq!( app.textarea.cursor(), (0, 1), "Up at first row must not move cursor" ); }
// ── decorate() ────────────────────────────────────────────────────────── // // Kills input.rs:55:5 `replace decorate → … with (Default::default(), 0)` and // `replace decorate → … with (Default::default(), 1)`. // // Both stubs return an empty DecorationMap AND word_count=0 or 1. A real // markdown decoration pass on multi-word text with a heading returns a // non-empty map AND word_count > 1.
// Helper: build a DecorateRequest from an App snapshot (for tests). fn make_req(text: &str, app: &App) -> DecorateRequest { DecorateRequest { text: text.to_string(), mode: app.file_mode.clone(), theme: app.theme.clone(), italic_support: app.italic_support, cache: app.highlight_cache.clone(), } }
// FileMode::Markdown: heading + body text → non-empty map, word_count > 1. #[test] fn decorate_markdown_returns_nonempty_map_and_correct_word_count() { let mut app = make_app(); app.file_mode = FileMode::Markdown; let text = "# Hello World\nThis is some text.\n"; let (map, wc) = run_decorate(&make_req(text, &app)); assert!( !map.is_empty(), "Markdown decoration must produce a non-empty DecorationMap" ); // "Hello World This is some text" = 7 words; stubs return 0 or 1. assert!( wc > 1, "Markdown decoration word count must be > 1 for multi-word text (got {wc})" ); }
// FileMode::PlainText: no decoration, word count only. #[test] fn decorate_plain_text_returns_empty_map_with_word_count() { let mut app = make_app(); app.file_mode = FileMode::PlainText; let (map, wc) = run_decorate(&make_req("hello world\n", &app)); assert!( map.is_empty(), "PlainText mode must return an empty DecorationMap" ); assert_eq!(wc, 2, "PlainText mode must still count words correctly"); }
// ── screen_to_doc ────────────────────────────────────────────────────────
// Helper: editor area spanning the full terminal at (0,0), width includes // two GUTTER columns (1 each side), so content_width = area.width - 2. fn editor_rect(width: u16, height: u16) -> Rect { Rect { x: 0, y: 0, width, height, } }
// Helper: build a DecorationMap with a single span on logical line `li` // whose only non-default field is `continuation_indent`. fn dec_map_with_ci(li: usize, ci: u8) -> DecorationMap { use yame::decoration::StyledSpan; let mut map = DecorationMap::default(); map.insert( li, vec![StyledSpan { continuation_indent: ci, ..StyledSpan::default() }], ); map }
// Click outside the editor area returns None. #[test] fn screen_to_doc_outside_area_returns_none() { let area = editor_rect(12, 5); let lines: Vec<String> = vec!["hello".into()]; let map = DecorationMap::default(); // Row above area assert!( screen_to_doc( 0, 0, &Rect { x: 0, y: 2, width: 12, height: 5 }, 0, &lines, &map, false, ) .is_none() ); // Col outside area assert!(screen_to_doc(0, 20, &area, 0, &lines, &map, false).is_none()); }
// Plain (no continuation indent) line: click at gutter+2 → doc col 2. #[test] fn screen_to_doc_plain_line_click() { // area width=12 → GUTTER=1 each side → cw=10 let area = editor_rect(12, 5); let lines: Vec<String> = vec!["hello world".into()]; let map = DecorationMap::default(); // screen_col = GUTTER + 2 = 3 → click_col = 2 → char 2 = 'l' let result = screen_to_doc(0, 3, &area, 0, &lines, &map, false); assert_eq!(result, Some((0, 2)), "plain click must map col correctly"); }
// Regression: click on the *third* visual row of a wrapped list item must // map to the same logical line (0), not the next logical line (1). // // Setup: cw=10, ci=2 → cont_width=8 // line 0: "- abc defgh ijk" // wrap_line_indented → ["- abc", "defgh", "ijk"] (3 visual rows) // line 1: "next line" // // Old bug: wrap_line (ignoring ci) gave ["- abc", "defgh ijk"] (2 rows), // so vis row 2 was counted as the start of line 1. #[test] fn screen_to_doc_list_item_third_wrap_row_lands_on_correct_logical_line() { let area = editor_rect(12, 10); // cw = 10 let lines: Vec<String> = vec!["- abc defgh ijk".into(), "next line".into()]; let map = dec_map_with_ci(0, 2); // Visual row 2 is the "ijk" continuation row of line 0. // screen_col = GUTTER(1) + ci(2) = 3 → clicking at the first char of "ijk". let result = screen_to_doc(2, 3, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(0), "third visual row of wrapped list item must map to logical line 0" ); }
// Column mapping on a continuation row must subtract the continuation // indent before computing the char position. // // Continuation row "defgh" of "- abc defgh ijk" at ci=2: // screen_col = GUTTER(1) + ci(2) + 3 = 6 → click_col=5, col_in_row=5-2=3 // "defgh"[0..3] = "def" → char index 3 within "defgh" → char 8 in original // ("- abc " = 6 chars, "defgh" starts at 6, char 3 within it → global 9) #[test] fn screen_to_doc_list_item_continuation_column_adjusted() { let area = editor_rect(12, 10); // cw = 10 // "- abc defgh ijk": '- abc ' = 6 chars, 'defgh' starts at char 6 let lines: Vec<String> = vec!["- abc defgh ijk".into(), "next line".into()]; let map = dec_map_with_ci(0, 2); // Visual row 1 = "defgh" continuation row. // screen_col = 1 (GUTTER) + 2 (ci) + 3 = 6 → click_col=5, col_in_row=3 // → chars_for_display_cols("defgh", 3) = 3 → doc_col = 6 + 3 = 9 let result = screen_to_doc(1, 6, &area, 0, &lines, &map, false); assert_eq!( result, Some((0, 9)), "continuation row column must be adjusted by continuation_indent" ); }
// Click where screen_col < editor_area.x (not 0) must return None. // // The bounds check is `screen_col < editor_area.x`. When area.x == 0, that // condition is unreachable (u16 < 0 never holds), so these mutants: // · `< → ==` (col 23): `screen_col == area.x` is false when col < x // · `< → <=` (col 23): admits clicks where col == x (inside area) as None // · `|| → &&` (col 9 of the joined condition): the combined guard collapses // to a conjunction — only returns None when ALL four conditions are true // …are only observable with area.x > 0. #[test] fn screen_to_doc_outside_area_nonzero_x_returns_none() { // area starts at x=3, y=1; any click with screen_col < 3 or screen_row < 1 // (while the other coordinate is inside) must return None. let area = Rect { x: 3, y: 1, width: 10, height: 5, }; let lines: Vec<String> = vec!["hello".into()]; let map = DecorationMap::default();
// screen_col=2 < area.x=3; screen_row=2 is in [1,6) — only col violates bounds. // With `||→&&` mutation the conjunction fires only if ALL four hold, which is // false here (row is valid), so the mutant would NOT return None. assert!( screen_to_doc(2, 2, &area, 0, &lines, &map, false).is_none(), "col below area.x must return None even when row is in-bounds" );
// screen_col=2 < area.x=3 and screen_row=0 < area.y=1 — both out of bounds. assert!( screen_to_doc(0, 2, &area, 0, &lines, &map, false).is_none(), "both row and col below area must return None" ); }
// A click exactly AT screen_col == editor_area.x is INSIDE the area (it // lands on the first gutter column). With the `< → <=` mutation this click // would incorrectly return None. #[test] fn screen_to_doc_click_at_area_x_edge_returns_some() { // area.x = 2, so the valid column range is [2, 12). screen_col = 2 (= area.x) // is the first valid column. click_col = 2 - (x=2 + GUTTER=1) saturating = 0. let area = Rect { x: 2, y: 0, width: 10, height: 5, }; let lines: Vec<String> = vec!["hello".into()]; let map = DecorationMap::default(); let result = screen_to_doc(0, 2, &area, 0, &lines, &map, false); assert!( result.is_some(), "screen_col == area.x is inside the area and must return Some" ); }
// When editor_area.y > 0, click_vis_row must subtract area.y, not add it. // With the `- → +` mutation click_vis_row = screen_row + area.y, which maps // clicks to the wrong (much-further-down) visual row. #[test] fn screen_to_doc_area_y_offset_subtracted() { // area.y = 1 — editor starts one row down from the terminal top. // Lines: ["aaa", "bbb", "ccc"] — each one visual row. // Click at screen_row=2: correct click_vis_row = 2-1 = 1 → logical line 1 ("bbb"). // With `+ mutation: click_vis_row = 2+1 = 3 → falls off the 3-row doc → line 2. let area = Rect { x: 0, y: 1, width: 12, height: 5, }; let lines: Vec<String> = vec!["aaa".into(), "bbb".into(), "ccc".into()]; let map = DecorationMap::default(); let result = screen_to_doc(2, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(1), "area.y must be subtracted (not added) from screen_row to get click_vis_row" ); }
// Clicking on the FIRST visual row of the SECOND logical line must map to // logical line 1, not logical line 0. // // Kills `> → >=` (line 88): with `>=`, vis+seg_count == click_vis_row enters // line 0's block one row early and returns (0, …) instead of (1, …). // // Also kills `+= → -=` (line 105): vis -= seg_count underflows to usize::MAX // in debug mode → panic → test failure. #[test] fn screen_to_doc_click_first_row_of_second_line() { // Two single-row lines; visual row 1 is the sole row of "world". let area = editor_rect(12, 5); let lines: Vec<String> = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); let result = screen_to_doc(1, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(1), "vis-row 1 (= seg_count of line 0) must map to logical line 1" ); }
// When the click is on a line after the first, `si = click_vis_row - vis` // must use subtraction so it points to the correct sub-row within the line. // // Kills `- → +` (line 89): si = click_vis_row + vis → huge index → row_str="" // → chars_into_row=0 → doc_col=0 regardless of click_col. #[test] fn screen_to_doc_click_column_on_second_line() { // Click at screen_col=4 → click_col = 4-GUTTER=3. "world"[0..3]="wor" → col 3. let area = editor_rect(12, 5); let lines: Vec<String> = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); // screen_col = GUTTER(1) + 3 = 4 let result = screen_to_doc(1, 4, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 3)), "si must use click_vis_row - vis so column offset lands on 'r' in 'world'" ); }
// cw must subtract GUTTER + GUTTER (= 2) from area.width, not a different value. // // "abcdefghij" is exactly 10 chars. With area.width=12, correct cw=10 → fits // on one visual row, so vis_row 1 maps to line 1. With the `+→-` mutation // (cw = width - 0 = 12) the line also fits, same result — but with the // `+→*` mutation (GUTTER * GUTTER = 1, cw = 11) it also fits. The mutation // that DOES produce a wrong cw is `+→-` giving 12 which still fits... hmm. // // Actually the key mutation to kill here is the old `* → +` form, now // expressed as `+ → *` after rewriting to `GUTTER + GUTTER`: // GUTTER * GUTTER = 1 × 1 = 1 → cw = 12-1=11 (still fits). The `+ → -` // form gives cw = 12-0=12. Both still fit for a 10-char word. // // Use a content-width-sensitive scenario instead: line = "ab cd efghi" // (5+1+2+1+5 = 14 chars). At cw=10 it wraps: ["ab cd", "efghi"] (2 rows). // At cw=9 it wraps: ["ab cd", "efghi"] too (word break before efghi). Need // cw == word length exactly. "0123456789" (10 chars) at cw=10 fits (1 row); // at cw=9 hard-breaks into ["012345678","9"] (2 rows). Click at vis_row=1: // correct (cw=10): line 1 ("second"). // +→* mutant (cw=12-1=11): "0123456789" still fits → vis_row 1 = line 1. Same! // // Better: use "0123456789" at width=12 (cw=10 correct, cw=12 with +→- mutant). // At cw=10: ["0123456789"] 1 row. At cw=12: still 1 row (fits). Same. // // The observable difference requires cw to drop below 10. With area.width=12: // correct: cw = 12 - (1+1) = 10 → 1 row // +→* mutant: cw = 12 - (1*1) = 11 → 1 row (same) // +→- mutant: cw = 12 - (1-1) = 12 → 1 row (same) // // With area.width=11: // correct: cw = 11 - 2 = 9 → hard-break: ["012345678","9"] 2 rows // +→* mutant: cw = 11 - 1 = 10 → "0123456789" fits in 1 row // +→- mutant: cw = 11 - 0 = 11 → 1 row // Click at vis_row=1 with 2 lines ["0123456789", "second"]: // correct (cw=9): line 0 has 2 rows → vis_row 1 is in line 0 → returns (0,_) // +→* mutant (cw=10): line 0 has 1 row → vis_row 1 is line 1 → returns (1,_) #[test] fn screen_to_doc_content_width_gutter_subtraction() { // area.width=11. Correct: cw = 11 - (GUTTER+GUTTER) = 11-2 = 9. // "0123456789" (10 chars) at cw=9: hard-breaks into 2 rows. // Click at vis_row=1 → INSIDE line 0 (not line 1 "second"). let area = Rect { x: 0, y: 0, width: 11, height: 5, }; let lines: Vec<String> = vec!["0123456789".into(), "second".into()]; let map = DecorationMap::default(); let result = screen_to_doc(1, 1, &area, 0, &lines, &map, false); assert_eq!( result.map(|(r, _)| r), Some(0), "with cw = width - (GUTTER+GUTTER), a 10-char line at width=11 wraps into 2 rows" ); }
// si == 0 (first visual row of a list item) must NOT subtract continuation_indent // from click_col, because the first row is not indented. // // Kills `> → >=` (line 96): with `>=`, si=0 also subtracts line_ci, so // col_in_row = click_col.saturating_sub(2) instead of click_col. #[test] fn screen_to_doc_list_item_first_row_no_ci_subtraction() { // "- hello" (7 chars) fits on one visual row at cw=10. ci=2. // Click at screen_col = GUTTER(1)+4 = 5 → click_col=4. // si=0 (first row): col_in_row must be 4 (no ci subtraction). // With `>=` mutant: col_in_row = 4 - 2 = 2 → returns (0, 2) instead of (0, 4). let area = editor_rect(12, 5); // cw=10 let lines: Vec<String> = vec!["- hello".into()]; let map = dec_map_with_ci(0, 2); let result = screen_to_doc(0, 5, &area, 0, &lines, &map, false); assert_eq!( result, Some((0, 4)), "si=0 (first row of list item) must not subtract continuation_indent from click_col" ); }
// A click below all content (vis_row > last logical line) must land at the // END of the last line, not col 0. // // Old behaviour: fallback was `Some((last, 0))`. // New behaviour: `Some((last, last_line.chars().count()))`. // // This matches every major text editor's convention: clicking in empty space // below the text is treated as clicking past the end of the last line. #[test] fn screen_to_doc_click_below_all_content_lands_at_end_of_last_line() { // Two lines, each one visual row. screen_row=5 is below both lines // (only rows 0 and 1 are occupied). let area = editor_rect(12, 10); let lines: Vec<String> = vec!["hello".into(), "world".into()]; let map = DecorationMap::default(); let result = screen_to_doc(5, 1, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 5)), "click below all content must land at end of last line (col 5 = len('world'))" ); }
// Same fallback with an empty last line: end_col must be 0, not panic. #[test] fn screen_to_doc_click_below_content_with_empty_last_line() { let area = editor_rect(12, 10); let lines: Vec<String> = vec!["hello".into(), "".into()]; let map = DecorationMap::default(); let result = screen_to_doc(5, 1, &area, 0, &lines, &map, false); assert_eq!( result, Some((1, 0)), "click below content with empty last line must land at (last, 0)" ); }
// ── Navigation inertness ─────────────────────────────────────────────────
// Navigation keys (Up/Down/Left/Right) must NOT set last_keystroke. // // Setting last_keystroke arms the 50ms debounce timer that triggers a full // decoration pass. Pure cursor movement cannot change content, so the // decoration map is already valid — re-running it wastes CPU and causes // perceptible lag on large files. // // Kills: any mutation that routes nav keys through mark_keystroke() instead // of recompute_dirty(). #[test] fn nav_keys_do_not_set_last_keystroke() { for code in [ KeyCode::Up, KeyCode::Down, KeyCode::Left, KeyCode::Right, KeyCode::Home, KeyCode::End, KeyCode::PageUp, KeyCode::PageDown, ] { let mut app = make_app(); app.textarea.insert_str("line one\nline two"); app.last_keystroke = None; handle_key_event(&mut app, KeyEvent::new(code, KeyModifiers::NONE)); assert!( app.last_keystroke.is_none(), "{code:?} must not set last_keystroke (would trigger redundant decoration pass)" ); } }
// ── Exit-prompt cancellation ─────────────────────────────────────────────
// Pressing Esc while in ExitPrompt must return to Normal mode without // exiting (KeyOutcome::Continue). // // Regression guard for FEEDBACK-1 §1.1: the original code matched // (NONE, Esc) at the outer level, shadowing the ExitPrompt handler. #[test] fn exit_prompt_esc_cancels_and_returns_to_normal() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!( outcome, KeyOutcome::Continue, "Esc in ExitPrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc in ExitPrompt must restore Normal mode" ); }
// Pressing 'c' (bare, or Ctrl+C) while in ExitPrompt must also cancel, // not copy to clipboard. The ExitPrompt handler matches on k.code only, // so modifiers do not affect it. #[test] fn exit_prompt_c_cancels_regardless_of_modifier() { for modifiers in [KeyModifiers::NONE, KeyModifiers::CONTROL] { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Char('c'), modifiers)); assert_eq!( outcome, KeyOutcome::Continue, "'c' (mods={modifiers:?}) in ExitPrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "'c' (mods={modifiers:?}) in ExitPrompt must restore Normal mode" ); } }
// Ctrl+Y / Ctrl+N while the exit prompt is open must NOT trigger // SaveAndExit / Exit — they are Redo / navigation shortcuts that the user // is likely to press accidentally. #[test] fn exit_prompt_ctrl_y_does_not_save_and_exit() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+Y must not trigger SaveAndExit" ); assert!( matches!(app.status.mode, yame::status::StatusMode::ExitPrompt), "ExitPrompt must remain open after Ctrl+Y" ); }
#[test] fn exit_prompt_ctrl_n_does_not_discard_and_exit() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+N must not trigger Exit" ); assert!( matches!(app.status.mode, yame::status::StatusMode::ExitPrompt), "ExitPrompt must remain open after Ctrl+N" ); }
// Tab key must insert spaces to the next tab stop, not a raw '\t'. // With app.tab_width=4 and cursor at col 0, pressing Tab should add 4 spaces. // // NOTE: these two tests use tab_width=4, which matches tui-textarea's default // tab_len (also 4). They verify correct behaviour but cannot kill the // `delete match arm Tab` mutant because tui-textarea's built-in Tab handler // happens to produce identical output for tab_width==4. The test below // (`tab_key_uses_app_tab_width_not_tui_textarea_default`) covers that case. #[test] fn tab_key_inserts_spaces_to_next_stop() { let mut app = make_app(); handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], " ", "Tab at col 0 with tab_width=4 must insert 4 spaces" ); }
// With the cursor already at col 2, pressing Tab should only insert 2 spaces // (to reach the next multiple-of-4 stop at col 4). #[test] fn tab_key_aligns_to_next_stop_mid_line() { let mut app = make_app(); // Type two chars to reach col 2. handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), ); handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "ab ", "Tab at col 2 with tab_width=4 must insert 2 spaces (align to col 4)" ); }
// Kills input.rs:308:9 `delete match arm (KeyModifiers::NONE, KeyCode::Tab)`. // // The existing two Tab tests use app.tab_width=4, which happens to equal // tui-textarea's own default tab_len (4). When the arm is deleted the key // falls through to `_ => { textarea.input(k) }`, and tui-textarea's built-in // handler inserts the same number of spaces → tests pass → mutant survives. // // Setting tab_width=2 breaks the equivalence: our arm inserts 2 spaces, but // tui-textarea (tab_len still 4) inserts 4 → assertion on 2 fails → mutant caught. #[test] fn tab_key_uses_app_tab_width_not_tui_textarea_default() { let mut app = make_app(); app.tab_width = 2; // differs from tui-textarea's default tab_len of 4 handle_key_event(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], " ", "Tab at col 0 with tab_width=2 must insert 2 spaces (not 4, which tui-textarea would produce)" ); }
// ── Exit-prompt y/n outcomes ──────────────────────────────────────────── // // Kills :218:13 (delete 'y' arm — 'y' would fall to `_` → Continue). #[test] fn exit_prompt_plain_y_saves_and_exits() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE) ), KeyOutcome::SaveAndExit, "plain 'y' in ExitPrompt must return SaveAndExit" ); }
// Kills :219:13 (delete 'n' arm — 'n' would fall to `_` → Continue). #[test] fn exit_prompt_plain_n_exits_without_saving() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE) ), KeyOutcome::Exit, "plain 'n' in ExitPrompt must return Exit" ); }
// Kills :212:77 `| → &` (missed mutant). // // With `| → &` on the *second* `|`, the guard becomes // `CONTROL | (ALT & SUPER)` = `CONTROL | 0` = `CONTROL`. // ALT is no longer included, so Alt+Y bypasses the guard and reaches the // `KeyCode::Char('y') => SaveAndExit` arm. #[test] fn exit_prompt_alt_y_is_suppressed() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::ExitPrompt; assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::ALT) ), KeyOutcome::Continue, "Alt+Y in ExitPrompt must be suppressed by the modifier guard" ); }
// ── Ctrl+S / Ctrl+X outcomes ──────────────────────────────────────────── // // Kills :234:9 (delete Ctrl+S arm — falls to `_` → textarea input, Continue). #[test] fn ctrl_s_returns_save() { let mut app = make_app(); assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL) ), KeyOutcome::Save, "Ctrl+S must return Save" ); }
// Kills: delete Ctrl+Q arm — falls to `_` → textarea input, Continue. #[test] fn ctrl_q_on_clean_file_returns_exit() { let mut app = make_app(); // is_dirty=false (default make_app) → handle_exit returns true → Exit. assert_eq!( handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL) ), KeyOutcome::Exit, "Ctrl+Q on a clean file must return Exit" ); }
// Kills: delete Ctrl+X cut arm — falls to `_` → textarea inserts 'x', Continue. #[test] fn ctrl_x_cuts_and_returns_continue() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["hello world".to_string()]); // Pre-seed clipboard as unavailable so arboard is never called in CI. // handle_cut is #[mutants::skip]; we only need to verify dispatch: the // Ctrl+X arm must call handle_cut, not fall through to `_` which calls // textarea.input(Ctrl+X) and inserts a literal 'x'. app.clipboard = yame::app::ClipboardState::Unavailable; // Select "hello". app.textarea.move_cursor(CursorMove::Head); app.textarea.start_selection(); for _ in 0..5 { app.textarea.move_cursor(CursorMove::Forward); } let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+X cut must return Continue" ); // With the arm present, handle_cut runs (clipboard unavailable → no-op) and // no 'x' is inserted. The `_` fallthrough inserts a literal 'x'. let text = app.textarea.lines()[0].clone(); assert!( !text.contains('x'), "Ctrl+X must not insert 'x' (got: {text:?})" ); }
// ── Shift+Down / Shift+Up visual-move-with-selection ──────────────────── // // Kills :296:9 (delete Shift+Down arm — missed mutant). // // Without the arm, Shift+Down falls to `_` which calls textarea.input(Shift+Down). // tui-textarea's built-in Shift+Down on a single-line document is a no-op (already // at the last logical line), leaving the cursor at (0, 0). The custom // handle_visual_move correctly steps to the second *visual* row of the wrapped line. // // Line "abcde fghij" at content_width=8: // wrap_line → ["abcde", "fghij"] — two visual rows. // First row chars 0..5, second row starts at char 6. // Cursor at (0, 0) → Shift+Down → (0, 6). #[test] fn shift_down_moves_by_visual_row() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event(&mut app, KeyEvent::new(KeyCode::Down, KeyModifiers::SHIFT)); assert_eq!( app.textarea.cursor(), (0, 6), "Shift+Down must step one visual row (col 0 → col 6 on wrapped line)" ); }
// Kills :297:9 (delete Shift+Up arm — timeout due to suite overhead). // // Without the arm, Shift+Up falls to `_` → textarea.input(Shift+Up) → no-op // on first logical line → cursor stays at (0, 6). Custom: moves to (0, 0). #[test] fn shift_up_moves_by_visual_row() { let mut app = nav_app(vec!["abcde fghij"], 8); app.textarea.move_cursor(CursorMove::Jump(0, 6)); handle_key_event(&mut app, KeyEvent::new(KeyCode::Up, KeyModifiers::SHIFT)); assert_eq!( app.textarea.cursor(), (0, 0), "Shift+Up must step back one visual row (col 6 → col 0 on wrapped line)" ); }
// --------------------------------------------------------------------------- // Search / replace integration tests // ---------------------------------------------------------------------------
/// Create an App with a single line of text, an active search for `query`, /// and a replacement string `replace`. The first match is pre-selected. fn make_search_app(content: &str, query: &str, replace: &str) -> App { let mut app = make_app(); app.textarea = TextArea::new(vec![content.to_string()]); let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(true); s.query = query.to_string(); s.replace = replace.to_string(); s.update_matches(&lines); app.search = Some(s); app }
// ---- T-24: do_replace_current replaces the active match ----
#[test] fn do_replace_current_replaces_first_match() { // Kills: lines 222:5 (`replace do_replace_current with ()`), // 230:24 (`replace - with +` and `replace - with /` in `me - ms`). // With `()`: textarea unchanged. // With `-→+` in `me - ms`: match_len = me + ms (wildly large) → over-deletes. let mut app = make_search_app("hello world", "world", "rust"); do_replace_current(&mut app); assert_eq!( app.textarea.lines()[0], "hello rust", "do_replace_current must replace 'world' with 'rust'" ); }
#[test] fn do_replace_current_does_not_over_delete_past_match_end() { // Specifically kills line 270:24 `replace - with +` in `match_len = me - ms`. // The match "mid" is at ms=4, me=7 in "pre mid post". // Correct: match_len = 7 - 4 = 3 → deletes only "mid" → "pre X post". // Wrong: match_len = 7 + 4 = 11 → deletes "mid post" (and more) → "pre X". // An end-of-string match obscures this because extra delete_next_char() // calls are no-ops; here there is text after the match that must survive. let mut app = make_search_app("pre mid post", "mid", "X"); do_replace_current(&mut app); assert_eq!( app.textarea.lines()[0], "pre X post", "text after the replaced match must be preserved" ); }
// ---- T-25: do_replace_all replaces every match ----
#[test] fn do_replace_all_replaces_every_occurrence() { // Kills: lines 252:5 (`replace do_replace_all with ()`), // 262:25 (`replace - with +` and `replace - with /` in `me - ms`). let mut app = make_search_app("aa aa aa", "aa", "b"); do_replace_all(&mut app); assert_eq!( app.textarea.lines()[0], "b b b", "do_replace_all must replace all 'aa' with 'b'" ); }
#[test] fn search_key_ctrl_a_triggers_replace_all_when_show_replace_true() { // Kills line 419:16 `replace match guard ... with false`. // With the guard forced false, Ctrl+A falls through to the swallow arm // and the buffer is never modified. let mut app = make_search_app("foo foo foo", "foo", "bar"); // make_search_app sets show_replace=true; confirm it. assert!(app.search.as_ref().unwrap().show_replace); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "bar bar bar", "Ctrl+A must trigger replace-all when show_replace is true" ); }
// ---- T-26: handle_search_key — one assertion per key binding ----
#[test] fn search_key_esc_closes_search() { // Kills: line 296:9 `delete match arm (KeyModifiers::NONE, KeyCode::Esc)` let mut app = make_search_app("hello", "hello", ""); handle_search_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( app.search.is_none(), "Esc must close search (search = None)" ); }
#[test] fn search_key_ctrl_f_advances_to_next_match() { // Kills: line 308:9 `delete match arm Ctrl+F / Enter` // Three matches; after Ctrl+F from current=0 → current=1. let mut app = make_app(); app.textarea = TextArea::new(vec!["aaa".to_string()]); let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(false); s.query = "a".to_string(); s.update_matches(&lines); // 3 matches, current=0 app.search = Some(s); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); let current = app.search.as_ref().unwrap().current; assert_eq!(current, 1, "Ctrl+F must advance current from 0 to 1"); }
#[test] fn search_key_shift_enter_retreats_to_prev_match() { // Kills: line 330:9 `delete match arm Shift+Enter` let mut app = make_app(); app.textarea = TextArea::new(vec!["aaa".to_string()]); let lines: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); let mut s = yame::search::SearchState::new(false); s.query = "a".to_string(); s.update_matches(&lines); // current = 0 s.current = 2; app.search = Some(s); handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); let current = app.search.as_ref().unwrap().current; assert_eq!(current, 1, "Shift+Enter must retreat current from 2 to 1"); }
#[test] fn search_key_backspace_pops_last_char_of_query() { // Kills: line 342:9 `delete match arm Backspace` let mut app = make_search_app("hello", "hell", ""); handle_search_key( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); let query = &app.search.as_ref().unwrap().query; assert_eq!(query, "hel", "Backspace must remove last char of query"); }
#[test] fn search_key_alt_r_toggles_regex_mode() { // Kills: line 351:9 `delete match arm Alt+R` and line 355:32 `delete !` let mut app = make_search_app("hello", "hello", ""); let before = app.search.as_ref().unwrap().regex_mode; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('r'), KeyModifiers::ALT), ); let after = app.search.as_ref().unwrap().regex_mode; assert_ne!(before, after, "Alt+R must toggle regex_mode"); }
#[test] fn search_key_ctrl_h_opens_replace_row() { // Kills: line 361:9 `delete match arm Ctrl+H` and line 363:34 `delete !` let mut app = make_search_app("hello", "hello", ""); app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); let show = app.search.as_ref().unwrap().show_replace; assert!(show, "Ctrl+H must enable show_replace"); // Toggle again: from true → false. handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); let show2 = app.search.as_ref().unwrap().show_replace; assert!(!show2, "second Ctrl+H must disable show_replace"); }
#[test] fn search_key_tab_swaps_focus_field() { // Kills: line 369:9 `delete match arm Tab` and line 373:34 `delete !` let mut app = make_search_app("hello", "hello", ""); app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().focus_search = true; handle_search_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let focus = app.search.as_ref().unwrap().focus_search; assert!(!focus, "Tab must switch focus from search to replace"); handle_search_key(&mut app, KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); let focus2 = app.search.as_ref().unwrap().focus_search; assert!(focus2, "second Tab must switch focus back to search"); }
#[test] fn search_key_char_appends_to_active_field() { // Kills: lines 385:14 `delete !`, 388:13 `&&→||`, and 385:14 match guard mutations. let mut app = make_search_app("hello", "he", ""); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('l'), KeyModifiers::NONE), ); let query = &app.search.as_ref().unwrap().query; assert_eq!(query, "hel", "printable char must append to query field"); }
#[test] fn search_key_ctrl_a_replaces_all_when_replace_visible() { // Kills: line 379:16 match guard mutations (`with true` / `with false`) let mut app = make_search_app("aa aa", "aa", "b"); app.search.as_mut().unwrap().show_replace = true; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "b b", "Ctrl+A must replace all matches when replace row is visible" ); }
#[test] fn search_key_ctrl_a_does_nothing_when_replace_hidden() { // Kills: line 379:16 `replace match guard with true` — guard must be false here. let mut app = make_search_app("aa aa", "aa", "b"); app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "aa aa", "Ctrl+A must do nothing when replace row is hidden" ); }
// ---- T-27: Normal-mode keys open search / replace ----
#[test] fn ctrl_f_opens_search_mode() { // Kills: line 511:9 `delete match arm (CONTROL, Char('f'))` let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); assert!( app.search.is_some(), "Ctrl+F must open search (search = Some)" ); assert!( !app.search.as_ref().unwrap().show_replace, "Ctrl+F must not open replace row" ); }
#[test] fn ctrl_h_opens_search_and_replace_mode() { // Kills: line 523:9 `delete match arm (CONTROL, Char('h'))` let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); assert!( app.search.is_some(), "Ctrl+H must open search (search = Some)" ); assert!( app.search.as_ref().unwrap().show_replace, "Ctrl+H must open replace row (show_replace = true)" ); }
// ---- T-23: handle_format_table reformats a misaligned table in-place ----
#[test] fn handle_format_table_reformats_table_in_place() { // Kills: lines 243:5 (`replace handle_format_table with ()`), // 258:25 (`replace + with -` and `replace + with *` in `start + i`). // With `()`: textarea unchanged. // With `+→-` in `start + i`: rows written at wrong lines (before start). // With `+→*` in `start + i`: rows written at wrong lines. let mut app = make_app(); app.textarea = TextArea::new(vec![ "| A | Bbb |".to_string(), "| - | --- |".to_string(), "| x | y |".to_string(), ]); app.textarea.move_cursor(CursorMove::Jump(0, 0)); yame::table_format::handle_format_table(&mut app); let out: Vec<String> = app.textarea.lines().iter().map(|s| s.to_string()).collect(); assert_eq!(out.len(), 3, "row count must be unchanged after format"); // All rows must have the same total length after alignment. assert_eq!( out[0].len(), out[2].len(), "header and body row must have equal length after format; rows: {out:?}" ); // Content must still be present (not wiped by no-op mutation). assert!(out[0].contains('A'), "header row must still contain 'A'"); assert!(out[2].contains('x'), "body row must still contain 'x'"); }
// ── Shortcuts modal ──────────────────────────────────────────────────────
// Kills: `replace false with true` on `show_shortcuts: false` in App::new / // make_app — the modal would be open before F1 is ever pressed. #[test] fn f1_opens_shortcuts_modal() { let mut app = make_app(); assert!(!app.show_shortcuts, "modal must start closed"); handle_key_event(&mut app, key(KeyCode::F(1))); assert!(app.show_shortcuts, "F1 must open the shortcuts modal"); }
// Kills: `replace false with true` / `delete app.show_shortcuts = false` in // the modal-swallow intercept — the modal would never close on Esc. #[test] fn esc_closes_shortcuts_modal() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::Esc)); assert!(!app.show_shortcuts, "Esc must close the shortcuts modal"); }
// Kills: `replace false with true` in the F(1) close arm — pressing F1 // while modal is open would re-open it immediately instead of closing. #[test] fn f1_closes_shortcuts_modal_when_open() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::F(1))); assert!( !app.show_shortcuts, "F1 while open must close the shortcuts modal" ); }
// Kills: removing the early-return in the modal-swallow intercept — a // character key pressed while the modal is open would reach the textarea. #[test] fn char_key_while_modal_open_does_not_reach_textarea() { let mut app = make_app(); app.show_shortcuts = true; handle_key_event(&mut app, key(KeyCode::Char('x'))); // Modal swallows the key — textarea stays empty AND modal stays open. assert_eq!( app.textarea.lines()[0], "", "char must be swallowed by modal" ); assert!( app.show_shortcuts, "modal must remain open on non-dismiss key" ); }
// ── Read-only mode ───────────────────────────────────────────────────────
// Typing a character in read-only mode must be blocked: the textarea stays // empty and a timed status message is set. #[test] fn read_only_blocks_char_input() { let mut app = make_app(); app.read_only = true; handle_key_event(&mut app, key(KeyCode::Char('a'))); assert_eq!( app.textarea.lines()[0], "", "char input must be blocked in read-only mode" ); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must be shown when blocked" ); }
// Quit key (Ctrl+Q) must still work in read-only mode. #[test] fn read_only_allows_quit_key() { let mut app = make_app(); app.read_only = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Exit, "Ctrl+Q must still quit in read-only mode" ); }
// Navigation (arrow keys) must pass through in read-only mode. #[test] fn read_only_allows_navigation() { let mut app = make_app(); app.read_only = true; // Insert two lines so Down has somewhere to go. app.read_only = false; app.textarea .input(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); app.read_only = true; let (row_before, _) = app.textarea.cursor(); handle_key_event(&mut app, key(KeyCode::Up)); let (row_after, _) = app.textarea.cursor(); assert!( row_after <= row_before, "Up arrow must move cursor in read-only mode" ); }
// Paste (Ctrl+V) must be blocked in read-only mode. #[test] fn read_only_blocks_paste() { let mut app = make_app(); app.read_only = true; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('v'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "", "paste must be blocked in read-only mode" ); }
// Undo (Ctrl+Z) must be blocked in read-only mode. #[test] fn read_only_blocks_undo() { let mut app = make_app(); app.read_only = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), ); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "undo must be blocked and trigger a status message in read-only mode" ); }
// is_ro_allowed must return true for every navigation key code. #[test] fn is_ro_allowed_permits_navigation_keys() { use KeyCode::{Down, End, Home, Left, PageDown, PageUp, Right, Up}; for code in [Up, Down, Left, Right, Home, End, PageUp, PageDown] { for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] { assert!( is_ro_allowed(mods, code), "{code:?} with {mods:?} must be allowed in read-only mode" ); } } }
// is_ro_allowed must return false for content-modifying keys. #[test] fn is_ro_allowed_blocks_edit_keys() { let blocked = [ (KeyModifiers::NONE, KeyCode::Backspace), (KeyModifiers::NONE, KeyCode::Delete), (KeyModifiers::NONE, KeyCode::Enter), (KeyModifiers::NONE, KeyCode::Tab), (KeyModifiers::NONE, KeyCode::Char('a')), (KeyModifiers::CONTROL, KeyCode::Char('s')), (KeyModifiers::CONTROL, KeyCode::Char('v')), (KeyModifiers::CONTROL, KeyCode::Char('z')), (KeyModifiers::CONTROL, KeyCode::Char('y')), (KeyModifiers::ALT, KeyCode::Char('t')), (KeyModifiers::CONTROL, KeyCode::Char('h')), ]; for (mods, code) in blocked { assert!( !is_ro_allowed(mods, code), "{code:?} with {mods:?} must be blocked in read-only mode" ); } }
// Ctrl+E must be allowed through the read-only guard (it's the toggle key). #[test] fn is_ro_allowed_permits_ctrl_e() { assert!( is_ro_allowed(KeyModifiers::CONTROL, KeyCode::Char('e')), "Ctrl+E must be allowed in read-only mode so the toggle works" ); }
// is_ro_allowed must permit Ctrl+A and Cmd+A (select-all is non-destructive). #[test] fn is_ro_allowed_permits_select_all() { assert!( is_ro_allowed(KeyModifiers::CONTROL, KeyCode::Char('a')), "Ctrl+A must be allowed in read-only mode" ); assert!( is_ro_allowed(KeyModifiers::SUPER, KeyCode::Char('a')), "Cmd+A must be allowed in read-only mode" ); }
// Kills: `lines().len()` replaced by 0 or 1. #[test] fn select_all_returns_line_count() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec![ "alpha".to_string(), "beta".to_string(), "gamma".to_string(), ]); let n = select_all(&mut app); assert_eq!(n, 3, "select_all must return the total line count"); }
// Kills: `start_selection()` deleted (no selection active after call). #[test] fn select_all_creates_active_selection() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["first line".to_string(), "second line".to_string()]); select_all(&mut app); assert!( app.textarea.selection_range().is_some(), "select_all must leave an active selection" ); }
// Kills: `move_cursor(CursorMove::Bottom)` or `move_cursor(CursorMove::End)` deleted // (cursor would not reach the last character). #[test] fn select_all_places_cursor_at_end_of_last_line() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["line one".to_string(), "line two".to_string()]); select_all(&mut app); let (row, col) = app.textarea.cursor(); assert_eq!(row, 1, "cursor row must be the last line after select_all"); assert_eq!( col, "line two".chars().count(), "cursor col must be end of last line after select_all" ); }
// Kills: input.rs:829:9 delete match arm Ctrl+A / Cmd+A in handle_key_event. // With arm deleted, Ctrl+A falls through to wildcard — no selection created. #[test] fn ctrl_a_selects_all_text() { let mut app = make_app(); app.textarea = tui_textarea::TextArea::new(vec!["hello".to_string(), "world".to_string()]); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL), ); assert!( app.textarea.selection_range().is_some(), "Ctrl+A must create an active selection" ); }
// Ctrl+E while read-only → disables read-only and shows a timed message. #[test] fn ctrl_e_unlocks_read_only_mode() { let mut app = make_app(); app.read_only = true; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(!app.read_only, "Ctrl+E must disable read-only mode"); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must confirm the mode change" ); }
// Ctrl+E while in edit mode → enables read-only. #[test] fn ctrl_e_locks_into_read_only_mode() { let mut app = make_app(); app.read_only = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(app.read_only, "Ctrl+E must enable read-only mode"); assert!( matches!( app.status.mode, yame::status::StatusMode::TimedMessage { .. } ), "a timed status message must confirm the mode change" ); }
// After unlocking with Ctrl+E, typing must reach the textarea. #[test] fn after_unlock_typing_reaches_textarea() { let mut app = make_app(); app.read_only = true; // Unlock. handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('e'), KeyModifiers::CONTROL), ); assert!(!app.read_only); // Type a character — must reach the textarea now. handle_key_event(&mut app, key(KeyCode::Char('z'))); assert_eq!( app.textarea.lines()[0], "z", "typing must reach textarea after Ctrl+E unlock" ); }
// ── Remaining search-key handler mutants ────────────────────────────────
#[test] fn search_key_ctrl_s_returns_save() { // Kills line 341:9 `delete match arm Ctrl+S in handle_search_key`. // Without the arm, Ctrl+S falls to the swallow arm and returns Continue. let mut app = make_search_app("hello", "hello", ""); let outcome = handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Save, "Ctrl+S must return Save even while search bar is open" ); }
#[test] fn search_key_ctrl_h_blocked_in_read_only() { // Kills line 401:56 `replace match guard !app.read_only with true`. // With the guard always true, Ctrl+H would toggle show_replace in read-only mode. let mut app = make_search_app("hello", "hello", ""); app.read_only = true; app.search.as_mut().unwrap().show_replace = false; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), ); assert!( !app.search.as_ref().unwrap().show_replace, "Ctrl+H must not open replace row when read_only is true" ); }
#[test] fn search_key_enter_on_replace_field_calls_replace_current() { // Kills lines 354:54 `replace && with ||` and 354:38 `delete !`. // When focus is on the replace field (focus_search=false) and show_replace=true, // Enter must call do_replace_current, modifying the buffer. let mut app = make_search_app("hello world", "world", "rust"); app.search.as_mut().unwrap().focus_search = false; app.search.as_mut().unwrap().show_replace = true; handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "hello rust", "Enter on replace field must replace the current match" ); }
#[test] fn search_key_enter_on_search_field_advances_not_replaces() { // Kills lines 350:40 `replace == with !=` and 351:17 `replace && with ||`. // With `!=`, Ctrl+F would mistakenly trigger replace_enter. // With `&&→||`, focus_search=true alone would not prevent replace_enter. // When focus is on the search field, Enter must advance the match without // modifying the buffer. let mut app = make_search_app("hello hello", "hello", "rust"); app.search.as_mut().unwrap().focus_search = true; app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().current = 0; handle_search_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( app.textarea.lines()[0], "hello hello", "Enter on search field must advance match selection, not replace text" ); // current must have advanced from 0 to 1. assert_eq!(app.search.as_ref().unwrap().current, 1); }
#[test] fn search_key_ctrl_f_with_replace_focused_advances_not_replaces() { // Kills line 350:40 `replace == with !=`. // With `!=`, Ctrl+F (k.code != Enter → true) + focus_search=false + show_replace=true // would make replace_enter=true, triggering a replace instead of advancing. let mut app = make_search_app("hello hello", "hello", "rust"); app.search.as_mut().unwrap().focus_search = false; app.search.as_mut().unwrap().show_replace = true; app.search.as_mut().unwrap().current = 0; handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::CONTROL), ); assert_eq!( app.textarea.lines()[0], "hello hello", "Ctrl+F must advance match even when replace field has focus" ); }
#[test] fn search_key_ctrl_char_not_treated_as_printable() { // Kills line 425:14 `replace match guard with true` and the `| with &` // mutations on line 427 (which reduce the mask to a single modifier bit, // allowing Ctrl+char to slip through as printable). // Ctrl+Z is not handled by any named arm; it must be swallowed, not appended. let mut app = make_search_app("hello", "he", ""); let before = app.search.as_ref().unwrap().query.clone(); handle_search_key( &mut app, KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), ); assert_eq!( app.search.as_ref().unwrap().query, before, "Ctrl+Z must be swallowed, not appended as 'z' to the query field" ); }
#[test] fn search_key_non_char_key_not_treated_as_printable() { // Kills line 428:13 `replace && with ||`. // With `||`, the arm matches whenever the guard is true OR k.code is Char(_). // An unhandled non-Char key (F5) with no modifier must be swallowed. let mut app = make_search_app("hello", "he", ""); let before = app.search.as_ref().unwrap().query.clone(); handle_search_key(&mut app, KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); assert_eq!( app.search.as_ref().unwrap().query, before, "F5 (non-Char key) must be swallowed, not appended to the query" ); }
// ── Ctrl+K: insert fenced code block ──────────────────────────────────────── // // Note: Ctrl+I = Tab; Alt+` unreliable on macOS without Meta key config.
// Kills: delete match arm `(CONTROL, Char('k'))`. // Without the arm, Ctrl+K falls into the `_` branch and the textarea receives // a literal control character instead of ```\n```. #[test] fn ctrl_k_inserts_code_block_on_empty_line() { let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let lines = app.textarea.lines(); assert_eq!(lines[0], "```", "first line must be opening fence"); assert_eq!(lines[1], "```", "second line must be closing fence"); }
// Kills: `col + 3 → col + 0` (or any wrong offset) in the CursorMove::Jump. // The cursor must land right after the opening ``` (col 3), not before or // further away. #[test] fn ctrl_k_cursor_lands_after_opening_ticks() { let mut app = make_app(); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let (row, col) = app.textarea.cursor(); assert_eq!(row, 0, "cursor must be on the opening fence line"); assert_eq!(col, 3, "cursor must be positioned after the opening ```"); }
// Cursor was mid-line before the insert — the block is injected at that point // and the cursor returns to the same row, 3 columns past where it started. // // Kills: using the post-insert row instead of the pre-insert row in Jump, or // using an absolute col=3 instead of col_before + 3. #[test] fn ctrl_k_cursor_offset_correct_when_not_at_col_zero() { let mut app = make_app(); app.textarea.insert_str("lang: "); // cursor now at col 6 handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); let (row, col) = app.textarea.cursor(); assert_eq!(row, 0, "cursor must be on the opening fence row"); assert_eq!( col, 9, "cursor must be 3 past the pre-insert column (6 + 3 = 9)" ); }
// force_redecorate must be set so the decoration pass picks up the new fences. // // Kills: removing `app.force_redecorate = true` from the Ctrl+K arm. #[test] fn ctrl_k_sets_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('k'), KeyModifiers::CONTROL), ); assert!( app.force_redecorate, "Ctrl+K must set force_redecorate so decoration reruns" ); }
#[test] fn alt_t_reformats_table_via_handle_key_event() { // Kills line 669:9 `delete match arm Alt+T in handle_key_event`. // The existing handle_format_table test calls the function directly; // this test verifies the keybinding wire-up in the dispatch table. let mut app = make_app(); app.textarea = TextArea::new(vec![ "| a | bb |".to_string(), "|---|---|".to_string(), "| c | d |".to_string(), ]); app.textarea.move_cursor(CursorMove::Jump(0, 0)); handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('t'), KeyModifiers::ALT), ); // After reformat all rows must have the same length. let l0 = app.textarea.lines()[0].len(); let l2 = app.textarea.lines()[2].len(); assert_eq!(l0, l2, "Alt+T must reformat table rows to equal length"); assert!(l0 > 0, "reformatted table must be non-empty"); }
// ── handle_save_as_key ───────────────────────────────────────────────────
fn make_untitled_app() -> App { let mut app = make_app(); app.file_path = None; app.shortened_path = "(untitled)".to_string(); app.status.start_save_as(false); app }
// Kills: delete the Char arm → typed chars are swallowed without being pushed. #[test] fn save_as_key_char_appends_to_buffer() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); assert_eq!( app.status.save_as_state().map(|(i, _)| i), Some("foo"), "typed characters must accumulate in the save-as buffer" ); }
// Kills: delete the Backspace arm → pop becomes a no-op. #[test] fn save_as_key_backspace_removes_last_char() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), ); assert_eq!( app.status.save_as_state().map(|(i, _)| i), Some("f"), "Backspace must remove the last character from the save-as buffer" ); }
// Kills: delete the Esc arm → Esc becomes a no-op and mode stays SaveAs. #[test] fn save_as_key_esc_cancels_prompt() { let mut app = make_untitled_app(); handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc must cancel the save-as prompt and return to Normal mode" ); }
// Kills: delete Enter arm → Enter becomes a no-op (no SaveAs outcome). #[test] fn save_as_key_enter_with_filename_emits_save_as_outcome() { let mut app = make_untitled_app(); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('.'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('m'), KeyModifiers::NONE), ); handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('d'), KeyModifiers::NONE), ); let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( matches!(outcome, KeyOutcome::SaveAs { ref filename, then_exit: false } if filename == "a.md"), "Enter with a filename must emit SaveAs outcome with the typed path" ); }
// Kills: invert `then_exit` when propagating from save_as_state → outcome. #[test] fn save_as_key_enter_propagates_then_exit_flag() { let mut app = make_untitled_app(); app.status.start_save_as(true); // then_exit = true handle_save_as_key( &mut app, KeyEvent::new(KeyCode::Char('b'), KeyModifiers::NONE), ); let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert!( matches!( outcome, KeyOutcome::SaveAs { then_exit: true, .. } ), "then_exit=true must be propagated to the SaveAs outcome" ); }
// Kills: delete the empty-filename guard → Enter on empty buffer emits SaveAs(""). #[test] fn save_as_key_enter_on_empty_buffer_stays_in_prompt() { let mut app = make_untitled_app(); // No chars typed — buffer is empty. let outcome = handle_save_as_key(&mut app, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!( outcome, KeyOutcome::Continue, "Enter on an empty filename must be ignored and keep the prompt open" ); assert!( matches!(app.status.mode, yame::status::StatusMode::SaveAs { .. }), "prompt must remain open when Enter is pressed with no filename" ); }
// Untitled buffer: Ctrl+S must open the save-as prompt, not return Save. // Kills: removing the `file_path.is_none()` guard → Ctrl+S returns Save on // untitled buffers, which would call handle_save_to_path on a None path and panic. #[test] fn ctrl_s_on_untitled_buffer_opens_save_as_prompt() { let mut app = make_app(); app.file_path = None; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+S on untitled buffer must return Continue (save-as prompt, not Save)" ); assert!( matches!( app.status.mode, yame::status::StatusMode::SaveAs { then_exit: false, .. } ), "Ctrl+S on untitled buffer must open the save-as prompt" ); }
// ── Ctrl+O / file picker ─────────────────────────────────────────────────
// Ctrl+O on a clean buffer should go straight to OpenPicker. // Kills: removing the `is_dirty` branch → always sets SwitchFilePrompt. #[test] fn ctrl_o_on_clean_buffer_returns_open_picker() { let mut app = make_app(); app.is_dirty = false; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::OpenPicker, "Ctrl+O on a clean buffer must return OpenPicker immediately" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must stay Normal when buffer is clean" ); }
// Ctrl+O on a dirty buffer must show the confirmation prompt, not open picker. // Kills: removing the `is_dirty` branch → dirty buffer opens picker without warning. #[test] fn ctrl_o_on_dirty_buffer_shows_switch_prompt() { let mut app = make_app(); app.is_dirty = true; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('o'), KeyModifiers::CONTROL), ); assert_eq!( outcome, KeyOutcome::Continue, "Ctrl+O on a dirty buffer must return Continue (prompt shown, not picker)" ); assert!( matches!(app.status.mode, yame::status::StatusMode::SwitchFilePrompt), "Ctrl+O on a dirty buffer must open the SwitchFilePrompt" ); }
// SwitchFilePrompt: Y confirms and returns OpenPicker. // Kills: Y arm returning Continue instead of OpenPicker. #[test] fn switch_file_prompt_y_returns_open_picker() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE), ); assert_eq!( outcome, KeyOutcome::OpenPicker, "Y in SwitchFilePrompt must return OpenPicker" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must return to Normal after confirming" ); }
// SwitchFilePrompt: N cancels and stays Normal. // Kills: N arm returning OpenPicker instead of Continue. #[test] fn switch_file_prompt_n_cancels() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE), ); assert_eq!( outcome, KeyOutcome::Continue, "N in SwitchFilePrompt must return Continue" ); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "status must return to Normal after cancelling" ); }
// SwitchFilePrompt: Esc cancels. // Kills: Esc arm missing → Esc becomes a no-op and mode stays SwitchFilePrompt. #[test] fn switch_file_prompt_esc_cancels() { let mut app = make_app(); app.status.mode = yame::status::StatusMode::SwitchFilePrompt; let outcome = handle_key_event(&mut app, KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert_eq!(outcome, KeyOutcome::Continue); assert!( matches!(app.status.mode, yame::status::StatusMode::Normal), "Esc must dismiss the SwitchFilePrompt" ); }
// --------------------------------------------------------------------------- // detect_list_prefix // ---------------------------------------------------------------------------
// Kills: delete the unordered-list arm → unordered lines return None. #[test] fn detect_list_prefix_unordered_dash() { let (cur, next) = detect_list_prefix("- item text").unwrap(); assert_eq!(cur, "- "); assert_eq!(next, "- "); }
// Kills: delete the unordered-list arm → `*` returns None. #[test] fn detect_list_prefix_unordered_star() { let (cur, next) = detect_list_prefix("* item").unwrap(); assert_eq!(cur, "* "); assert_eq!(next, "* "); }
// Kills: delete indent handling → indented prefix drops leading spaces. #[test] fn detect_list_prefix_indented_unordered() { let (cur, next) = detect_list_prefix(" - nested").unwrap(); assert_eq!(cur, " - "); assert_eq!(next, " - "); }
// Kills: delete ordered-list arm → ordered lines return None. #[test] fn detect_list_prefix_ordered_dot() { let (cur, next) = detect_list_prefix("1. first").unwrap(); assert_eq!(cur, "1. "); assert_eq!(next, "2. "); }
// Kills: replace num+1 with num → ordered continuation doesn't increment. #[test] fn detect_list_prefix_ordered_increments() { let (_, next) = detect_list_prefix("5. fifth").unwrap(); assert_eq!(next, "6. "); }
// Kills: delete ')' branch → ordered list with paren returns None. #[test] fn detect_list_prefix_ordered_paren() { let (cur, next) = detect_list_prefix("3) third").unwrap(); assert_eq!(cur, "3) "); assert_eq!(next, "4) "); }
// Kills: delete task-list arm → task items return the plain unordered prefix. #[test] fn detect_list_prefix_task_unchecked() { let (cur, next) = detect_list_prefix("- [ ] task item").unwrap(); assert_eq!(cur, "- [ ] "); assert_eq!(next, "- [ ] "); }
// Kills: delete checked-task branch → checked task returns unchecked next. #[test] fn detect_list_prefix_task_checked_continues_unchecked() { let (_, next) = detect_list_prefix("- [x] done").unwrap(); assert_eq!(next, "- [ ] "); }
// Kills: delete None branch → non-list lines return Some instead of None. #[test] fn detect_list_prefix_plain_text_returns_none() { assert!(detect_list_prefix("just a sentence").is_none()); assert!(detect_list_prefix("").is_none()); assert!(detect_list_prefix(" ").is_none()); }
// Heading lines must not be detected as list prefixes. #[test] fn detect_list_prefix_heading_returns_none() { assert!(detect_list_prefix("# heading").is_none()); }
// --------------------------------------------------------------------------- // handle_enter — list continuation // ---------------------------------------------------------------------------
// Kills: delete list-continuation arm → Enter on list line inserts plain newline (no prefix). #[test] fn enter_on_list_item_inserts_prefix() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- item".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "- ", "new line must start with list prefix" ); }
// Kills: delete exit-list branch → Enter on empty item inserts another prefix instead of exiting. #[test] fn enter_on_empty_list_item_exits_list() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- ".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); // Line count unchanged: the prefix was stripped, no new line added. assert_eq!( app.textarea.lines().len(), 1, "exit-list must not add a line" ); assert_eq!( app.textarea.lines()[0], "", "prefix must be stripped from empty item" ); }
// Kills: delete ordered increment → ordered continuation repeats the same number. #[test] fn enter_on_ordered_list_item_increments_number() { let mut app = make_app(); app.textarea = TextArea::new(vec!["3. third".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "4. ", "ordered continuation must use next number" ); }
// Kills: delete task-list branch → task item continuation uses wrong prefix. #[test] fn enter_on_task_item_continues_with_unchecked() { let mut app = make_app(); app.textarea = TextArea::new(vec!["- [x] done".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "- [ ] ", "task continuation must be unchecked" ); }
// Kills: delete plain-Enter fallback → Enter on non-list line does nothing. #[test] fn enter_on_plain_text_inserts_newline() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello world".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 2, "Enter on plain text must add a new line" ); assert_eq!(app.textarea.lines()[1], "", "new line must be empty"); }
// Kills: delete indent-inheritance arm → new line has no leading spaces. #[test] fn enter_on_indented_line_inherits_indent() { let mut app = make_app(); app.textarea = TextArea::new(vec![" hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!(app.textarea.lines().len(), 2, "must add a line"); assert_eq!( app.textarea.lines()[1], " ", "new line must carry the 4-space indent" ); }
// Kills: negate the is_empty guard → insert_str called on non-indented line (no-op, // but proves the condition is checked). Separate from the above to give a non-empty // indent case and an empty-indent case. #[test] fn enter_on_unindented_line_no_indent_inserted() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "", "unindented line must yield empty new line" ); }
// Kills: delete tab from take_while → tabs are not recognised as indent chars. #[test] fn enter_on_tab_indented_line_inherits_tab() { let mut app = make_app(); app.textarea = TextArea::new(vec!["\t\tcode".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines()[1], "\t\t", "tab-indented line must carry the tabs" ); }
// Kills: delete exit-indent branch → Enter on empty indented line adds another // indented blank line instead of stripping one indent level. #[test] fn enter_on_empty_indented_line_strips_one_level() { let mut app = make_app(); // Line 0 has content; line 1 is pure-whitespace indent (4 spaces, tab_width=4). app.textarea = TextArea::new(vec!["content".to_string(), " ".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::Down); handle_key_event(&mut app, key(KeyCode::Enter)); // Line count must stay the same (no new line inserted). assert_eq!( app.textarea.lines().len(), 2, "exit-indent must not add a line" ); assert_eq!( app.textarea.lines()[1], "", "indent must be stripped to empty" ); }
// Kills: replace tab_width with 1 (or a constant) in the strip calculation, // or replace .min with .max — 8 spaces → 4 stripped, not all 8. #[test] fn enter_on_double_indented_empty_line_strips_one_level_only() { let mut app = make_app(); app.textarea = TextArea::new(vec![" ".to_string()]); // 8 spaces, tab_width=4 handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 1, "exit-indent must not add a line" ); assert_eq!( app.textarea.lines()[0], " ", "only one tab_width stripped" ); }
// Kills: delete the `indent.len() == line.len()` guard → exit-indent fires on // indented lines with content, stripping their prefix instead of inheriting it. #[test] fn enter_on_indented_line_with_content_inherits_not_exits() { let mut app = make_app(); app.textarea = TextArea::new(vec![" hello".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::End); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!(app.textarea.lines().len(), 2, "must add a new line"); assert_eq!( app.textarea.lines()[1], " ", "must inherit indent, not exit" ); }
// Kills: strip = 1 for tabs → two tabs → one stripped, not two. #[test] fn enter_on_empty_tab_indented_line_strips_one_tab() { let mut app = make_app(); app.textarea = TextArea::new(vec!["\t\t".to_string()]); handle_key_event(&mut app, key(KeyCode::Enter)); assert_eq!( app.textarea.lines().len(), 1, "exit-indent must not add a line" ); assert_eq!(app.textarea.lines()[0], "\t", "one tab stripped, not both"); }
// ── Ctrl+X / F2 arms ────────────────────────────────────────────────────
// Kills: delete Ctrl+X arm — falls to `_` which only sets force_redecorate // when line count changes. Cutting a single-line selection preserves line // count, so the _ fallthrough leaves force_redecorate=false. #[test] fn ctrl_x_sets_force_redecorate() { let mut app = make_app(); app.textarea = TextArea::new(vec!["hello world".to_string()]); app.textarea.move_cursor(tui_textarea::CursorMove::Head); app.textarea.start_selection(); app.textarea.move_cursor(tui_textarea::CursorMove::Forward); app.force_redecorate = false; handle_key_event( &mut app, KeyEvent::new(KeyCode::Char('x'), KeyModifiers::CONTROL), ); assert!( app.force_redecorate, "Ctrl+X must set force_redecorate=true unconditionally" ); }
// Kills: delete F(2) arm — falls to `_` which returns Continue, not OpenSettings. #[test] fn f2_returns_open_settings() { let mut app = make_app(); assert_eq!( handle_key_event(&mut app, KeyEvent::new(KeyCode::F(2), KeyModifiers::NONE)), KeyOutcome::OpenSettings, "F2 must return OpenSettings" ); }
// Kills: replace != with == in the line-count guard inside the `_` arm. // With ==: force_redecorate is set when line count DOES NOT change (inverted). // Typing a plain char never changes line count, so the mutation would set // force_redecorate where the real code leaves it false. #[test] fn typing_char_does_not_set_force_redecorate() { let mut app = make_app(); app.force_redecorate = false; handle_key_event(&mut app, key(KeyCode::Char('a'))); assert!( !app.force_redecorate, "typing a plain char must not set force_redecorate (no line count change)" ); }
// ── detect_list_prefix boundary cases ───────────────────────────────────
// Kills: replace > with >= in the ordered-list digit_end guard. // With >=: digit_end==0 (no leading digits) enters the branch, incorrectly // treating ". item" as an ordered list with an implicit "1." prefix. #[test] fn detect_list_prefix_dot_without_leading_digit_returns_none() { assert!( detect_list_prefix(". item").is_none(), "a line starting with '.' but no leading digit must not match ordered list" ); }
// Kills: replace && with || in the ordered-list punctuation filter. // With ||: any char after the digits where the substring-after has a leading // space also matches (e.g. "1x item" where 'x' is not '.'/')' but " item" starts // with ' '). #[test] fn detect_list_prefix_digit_then_non_punct_returns_none() { assert!( detect_list_prefix("1x item").is_none(), "a digit followed by non-punctuation must not match ordered list" ); }}