Something went wrong. Try again.
A GPU terminal emulator for Wayland and Windows on libghostty-vt, drawing with Vulkan, running a program on a pty or a serial port.
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! One terminal window: a platform window, its tabs, and the renderer that//! draws the one shown — every pane of it, side by side and one above//! another as its splits have them.//!//! Keys, text and the clipboard go to the shown tab's focused pane; the//! pointer goes to the pane it is over, or the one a press began in, and a//! press in a pane focuses it. Everything here runs on the main thread.//! Each pane's session has an IO thread of its own, reached only through//! the session's lock, its outbox and its events.
const std = @import("std");const builtin = @import("builtin");const Allocator = std.mem.Allocator;const vt = @import("ghostty-vt");
const App = @import("App.zig");const Session = @import("termio/Session.zig");const Backend = @import("backend/Backend.zig");const aerie = @import("aerie");const platform = aerie.platform;const ev = platform.event;const keys = @import("input/keys.zig");const bindings = @import("input/bindings.zig");const bell_sounds_mod = @import("BellSounds.zig");const frame = @import("renderer/frame.zig");const Renderer = aerie.gpu.Renderer;const TitleBar = @import("decor/TitleBar.zig");const Grid = @import("aerie").text.Family;const location = @import("location.zig");const decor = @import("decor/decor.zig");const req = @import("request.zig");const cpu = aerie.gpu.cpu;const images = @import("renderer/images.zig");const Tab = @import("Tab.zig");const Pane = @import("Pane.zig");const TabBar = @import("decor/TabBar.zig");const StatusWindow = @import("StatusWindow.zig");const StatusButton = @import("decor/StatusButton.zig");const Search = @import("Search.zig");const ProfileSelector = @import("ProfileSelector.zig");const Confirmation = @import("Confirmation.zig");const Toast = @import("Toast.zig");const foreground = @import("os/foreground.zig");const l10n = @import("l10n.zig");const Scrollbar = @import("decor/Scrollbar.zig");const links = @import("links.zig");const startup = @import("startup.zig");
/// Where finished frames go: on Wayland, the compositor's dma-bufs or shared/// memory; on Windows, a swapchain.pub const Target = aerie.gpu.Target;
const Window = @This();
const log = std.log.scoped(.window);
app: *App,gpa: Allocator,pw: *platform.Window,/// Never empty while the window is open; the window closes with its last.tabs: std.ArrayList(*Tab) = .empty,/// The one shown.active: usize = 0,tab_bar: TabBar = .{},renderer: Renderer,target: Target,builder: frame.Builder,
/// The window in physical pixels, and how many of those to a logical one.width: u32 = 0,height: u32 = 0,scale: f32 = 1,padding: u32 = 0,
focused: bool = false,/// Something on screen changed since the last frame.dirty: bool = true,/// The generation the next confirmation's image preview is given.previews: u64 = 1,/// The window is to be closed at the end of this turn of the loop.closing: bool = false,/// Where the tabs go: inside the frame and below the bar, in physical/// pixels. Empty until the window has been laid out.content: Pane.Rect = .{},
pointer_x: f32 = 0,pointer_y: f32 = 0,left_down: bool = false,/// The pane a left press began in, which the rest of the drag belongs to/// wherever the pointer goes.press_pane: ?*Pane = null,/// When a selection dragged past its pane's top or bottom next scrolls/// the pane a row; null while none is.autoscroll_at: ?std.Io.Timestamp = null,/// Where a left press was, until it is released or dragged far enough to/// ask the program under it to offer a drag.dnd_press: ?struct { x: f32, y: f32 } = null,/// The link under the pointer while ctrl is held, drawn underlined.link_hover: ?LinkHover = null,/// The pointer is over a pane's grid, as of the last motion.pointer_in_content: bool = false,/// Whether ctrl was held when the hover was last looked at.link_ctrl: bool = false,/// A press opened a link, and its release goes nowhere.link_press: bool = false,/// A divider being dragged.divider_drag: ?*Tab.Split = null,/// A scrollbar's thumb being dragged, and where on it it was taken.scroll_drag: ?struct { pane: *Pane, grab: f32 } = null,/// The pane whose handle is shown, the pointer being near the top of it,/// and whether the pointer is on the handle itself. Only compared with,/// never followed: it may be a pane since closed.handle_pane: ?*Pane = null,handle_hover: bool = false,/// A pane taken by its handle, to be dropped beside another in its tab.pane_drag: ?PaneDrag = null,/// When a sweeping progress bar is next drawn.sweep_at: std.Io.Timestamp = .zero,/// When the focused pane's location is next looked for in `/proc`, while/// the subtitle shows it and its shell has not said with OSC 7.location_check_at: std.Io.Timestamp = .zero,/// Where the status badge is, over the terminal at the top right, when/// neither the title bar nor the tab bar is there to carry the status/// button; empty when it is not shown.badge: Pane.Rect = .{},badge_hover: bool = false,/// A press on the badge, whose release opens the status window.badge_press: bool = false,
/// The profile selector, while it is open.profile_selector: ?ProfileSelector = null,/// Where it is, and where its rows are, as of the last frame; empty while/// it is closed.profile_selector_box: Pane.Rect = .{},profile_selector_list: Pane.Rect = .{},profile_selector_row_h: u32 = 1,/// The row the pointer is over.profile_selector_hover: ?usize = null,/// Scrolling smaller than a row, kept until it adds up to one.profile_selector_scroll_rest: f32 = 0,
preedit: ?[]u8 = null,cursor_on: bool = true,
/// Our own title bar and frame, when nobody else draws them.title_bar: TitleBar = .{},/// When the empty part of the tab bar was last pressed, likewise.last_bar_press: ?std.Io.Timestamp = null,/// The pointer's shape, as last set.pointer_shape: ev.Cursor = .text,
const PaneDrag = struct { pane: *Pane, /// The pane under the pointer, and the side of it the dragged one /// would go to; none over the dragged pane itself. target: ?*Pane = null, side: Tab.Direction = .left,};
/// The shown tab.pub fn tab(w: *const Window) *Tab { return w.tabs.items[w.active];}
/// The focused pane of the shown tab.pub fn pane(w: *const Window) *Pane { return w.tab().focused;}
pub fn create(app: *App, request: *const req.Request) !*Window { const config = app.config(); const cols = @max(config.window.columns, 2); const rows = @max(config.window.rows, 1); const first = try Tab.create(app, request, .{ .cols = cols, .rows = rows }); errdefer first.destroy(); return createWith(app, first, request.activation_token);}
/// A window around `first`, a tab that already exists — one dragged out of/// another window — sized to fit it as it was. Takes the tab only if it/// succeeds.pub fn createWith(app: *App, first: *Tab, activation_token: ?[]const u8) !*Window { const gpa = app.gpa; const w = try gpa.create(Window); errdefer gpa.destroy(w);
const config = app.config(); const m = app.grid.metrics; const padding: u32 = config.window.padding; // The tab's area where it was, or its one pane's grid for a new one. const size = first.focused.session.wanted; const content_w: u32 = if (!first.area.empty()) first.area.w else @as(u32, size.cols) * m.cell_width + 2 * padding; const content_h: u32 = if (!first.area.empty()) first.area.h else @as(u32, size.rows) * m.cell_height + 2 * padding;
// The size the window asks for, in logical pixels at the scale the // grid was last made for; the compositor's configure says what it gets. // When harrier will draw the frame itself, the window is that much // bigger, so that the grid is still the size asked for; likewise for a // tab bar that is there from the start. var expect_decor: TitleBar = .{ .bar = .{ .enabled = config.window.title_bar == .client or (config.window.title_bar == .server and !app.platform.serverTitleBar()), .native_frame = app.platform.drawsFrame(), .scale = app.scale, } }; if (expect_decor.bar.enabled) expect_decor.lines(app.titleFamily(), config.window.subtitle == .location); const expect_bar: TabBar = .{ .mode = tabBarMode(config.window.tab_bar), .scale = app.scale, .cell_height = m.cell_height, }; const in = expect_decor.bar.insets(); const width_px = content_w + in.left + in.right; const height_px = content_h + in.top + in.bottom + expect_bar.height(); const pw = try platform.Window.create(app.platform, .{ .title = "harrier", .app_id = req.app_id, .text_purpose = .terminal, .activation_token = activation_token, .width = @intFromFloat(@round(@as(f32, @floatFromInt(width_px)) / app.scale)), .height = @intFromFloat(@round(@as(f32, @floatFromInt(height_px)) / app.scale)), .title_bar = switch (config.window.title_bar) { .server => .server, .client => .client, .none => .none, }, .user = w, }); errdefer pw.destroy();
var renderer: Renderer = try .init(app.device); errdefer renderer.deinit(); var target = try makeTarget(app, pw, width_px, height_px); errdefer target.deinit();
w.* = .{ .app = app, .gpa = gpa, .pw = pw, .renderer = renderer, .target = target, .builder = try .init(gpa, app.io, .{ .features = config.font.features, .ligatures = config.font.ligatures, .builtin_glyphs = config.font.builtin_glyphs, }), .tab_bar = .{ .mode = tabBarMode(config.window.tab_bar) }, }; errdefer w.builder.deinit(); try w.tabs.append(gpa, first); w.showTitle(); w.applyTranslucency(); return w;}
fn tabBarMode(mode: anytype) TabBar.Mode { return switch (mode) { inline else => |m| @field(TabBar.Mode, @tagName(m)), };}
// ---------------------------------------------------------------------------// Tabs// ---------------------------------------------------------------------------
/// Opens a tab after the shown one, and shows it.pub fn newTab(w: *Window, request: *const req.Request) !void { const size = if (w.content.empty()) w.pane().session.wanted else w.gridOf(w.content); const t = try Tab.create(w.app, request, size); errdefer t.destroy(); try w.tabs.insert(w.gpa, w.active + 1, t); const was_visible = w.tab_bar.visible(); w.select(w.active + 1); w.tabsChanged(was_visible);}
/// A tab like the shown one would get: in the same directory.pub fn newTabHere(w: *Window) void { var r: req.Request = .{ .cwd = w.pane().workingDirectory(w.gpa, true) }; defer r.deinit(w.gpa); w.app.likeProfile(&r, w.pane()) catch {}; w.newTab(&r) catch |err| log.err("new tab: {t}", .{err});}
/// Shows tab `i`.pub fn select(w: *Window, i: usize) void { if (i >= w.tabs.items.len) return; // A program that asked to be told about focus is told it lost it when // its tab is hidden, and got it back when it is shown. const changed = i != w.active; if (changed and w.focused and w.active < w.tabs.items.len) w.reportFocus(w.pane(), false); w.active = i; w.tab_bar.active = i; if (changed and w.focused) w.reportFocus(w.pane(), true); // Every row of the newly shown terminals is drawn afresh. w.restale(w.tab()); w.dirty = true; w.showTitle();}
/// Closes tab `i`, and the window with its last.pub fn closeTab(w: *Window, i: usize) void { const t = w.takeTab(i) orelse return; var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { w.app.withdraw(p); if (w.link_hover) |h| if (h.pane == p) { w.link_hover = null; }; } w.app.forgetTab(t); t.destroy();}
/// Takes tab `i` out of the window, to be put in another or closed. The/// window closes once its last is gone.pub fn takeTab(w: *Window, i: usize) ?*Tab { if (i >= w.tabs.items.len) return null; const was_visible = w.tab_bar.visible(); const was_shown = i == w.active; const t = w.tabs.orderedRemove(i); if (w.tabs.items.len == 0) { w.active = 0; w.closing = true; return t; } // The tab to the right takes a closed shown tab's place, or the one to // the left when it was the last. One closed to the left of the shown // tab moves it along by one. if (i < w.active or w.active >= w.tabs.items.len) w.active -= 1; w.tab_bar.active = w.active; if (was_shown) { // The tab that lost the focus is gone; the one shown now gets it. if (w.focused) w.reportFocus(w.pane(), true); w.restale(w.tab()); w.dirty = true; w.showTitle(); } w.tabsChanged(was_visible); return t;}
/// Puts `t`, taken from another window, at place `slot` in this one, and/// shows it.pub fn insertTab(w: *Window, t: *Tab, slot: usize) !void { const at = @min(slot, w.tabs.items.len); // The tab shown until now loses the focus, told before the list moves // under its index. if (w.focused) w.reportFocus(w.pane(), false); errdefer if (w.focused) w.reportFocus(w.pane(), true); try w.tabs.insert(w.gpa, at, t); const was_visible = w.tab_bar.visible(); w.active = at; w.tab_bar.active = at; if (w.focused) w.reportFocus(w.pane(), true); w.restale(t); w.dirty = true; w.showTitle(); w.tab_bar.count = w.tabs.items.len; // It is laid out in this window, whether or not the bar came with it. if (w.tab_bar.visible() != was_visible) w.barToggled() else if (!w.content.empty()) w.layoutTab(t);}
/// Moves the shown tab `delta` places along, stopping at either end.pub fn moveTab(w: *Window, delta: i32) void { const last: i64 = @intCast(w.tabs.items.len - 1); w.moveTabTo(w.active, @intCast(std.math.clamp(@as(i64, @intCast(w.active)) + delta, 0, last)));}
/// Moves tab `from` to place `to`, the others closing up behind it; the/// shown tab stays shown wherever it ends up.pub fn moveTabTo(w: *Window, from: usize, to: usize) void { if (from == to or from >= w.tabs.items.len or to >= w.tabs.items.len) return; const shown = w.tab(); const t = w.tabs.orderedRemove(from); w.tabs.insertAssumeCapacity(to, t); w.active = std.mem.findScalar(*Tab, w.tabs.items, shown).?; w.tab_bar.active = w.active; w.dirty = true;}
/// Shows the tab `delta` places along, wrapping round.pub fn cycle(w: *Window, delta: i32) void { const n: i64 = @intCast(w.tabs.items.len); w.select(@intCast(@mod(@as(i64, @intCast(w.active)) + delta, n)));}
/// After a tab came or went: the bar may have come or gone with it, which/// changes the grid.fn tabsChanged(w: *Window, was_visible: bool) void { w.tab_bar.count = w.tabs.items.len; if (w.tab_bar.visible() != was_visible) { w.barToggled(); } else if (!w.content.empty()) for (w.tabs.items) |t| w.layoutTab(t); w.dirty = true;}
/// The tab bar came or went. A floating window grows or shrinks by the/// bar's height, so that the terminals keep their size; one whose size/// the compositor sets — maximized, fullscreen, tiled — stays as it is,/// and the bar takes its room from them or gives it back.fn barToggled(w: *Window) void { const bar = w.tab_bar.shownHeight(); const want = if (w.tab_bar.visible()) w.height + bar else w.height -| bar; // Rounded up, so that a fractional scale never costs a row. const logical: u32 = @intFromFloat(@ceil(@as(f32, @floatFromInt(want)) / w.scale)); if (w.pw.resizeTo(w.pw.width, logical)) w.height = @intFromFloat(@round(@as(f32, @floatFromInt(logical)) * w.scale)); w.layout();}
/// The window's title is the shown tab's: its focused pane's. Under it,/// the subtitle shows that pane's location, looked for again here, since/// this is where the focus or the title has changed. Shell search shows it/// too, so it is looked for whether or not there is a subtitle.fn showTitle(w: *Window) void { var buf: [1024]u8 = undefined; w.pw.setTitle(w.title(&buf)); _ = w.tab().focused.refreshLocation(); if (w.title_bar.bar.enabled) w.dirty = true;}
/// The window's title, in `buf`: the shown tab's, after a clipboard while/// any of the window's panes is asking unseen, and after a bell while any/// is rung and the bell marks it.fn title(w: *Window, buf: []u8) []const u8 { return marked(w.tab().title(), w.anyAsking(), w.anyRung(.mark), buf);}
/// `t`, after the marks for a pane asking and a pane rung, in `buf`.fn marked(t: []const u8, asking: bool, rung: bool, buf: []u8) []const u8 { if (!asking and !rung) return t; return std.fmt.bufPrint(buf, "{s}{s}{s}", .{ if (asking) ask_mark else "", if (rung) bell_mark else "", t }) catch t;}
/// What goes before the title of a window, or a tab, with a pane rung.const bell_mark = "\u{1F514} ";/// What goes before it with a pane asking a question not yet seen.const ask_mark = "\u{1F4CB} ";
/// Whether any of the window's panes is asking unseen.fn anyAsking(w: *const Window) bool { for (w.tabs.items) |t| if (tabAsking(t)) return true; return false;}
fn tabAsking(t: *const Tab) bool { var buf: [Tab.max_panes]*Pane = undefined; for (@constCast(t).panes(&buf)) |p| if (p.asking) return true; return false;}
/// Whether any of the window's panes is rung./// Whether a pane in it has rung whose configuration says to mark its/// title for that, or to ask for attention: `bell.mark` or/// `bell.attention`, which may be its foreground program's.pub fn anyRung(w: *const Window, comptime which: RungBy) bool { for (w.tabs.items) |t| if (w.tabRung(t, which)) return true; return false;}
pub const RungBy = enum { mark, attention };
fn tabRung(w: *const Window, t: *const Tab, comptime which: RungBy) bool { var buf: [Tab.max_panes]*Pane = undefined; for (@constCast(t).panes(&buf)) |p| if (p.rung and @field(w.app.configFor(p).bell, @tagName(which))) return true; return false;}
/// Whether the user is looking at `p`, in tab `ti`: the focused pane of the/// shown tab of a focused window.fn looksAt(w: *const Window, ti: usize, p: *const Pane) bool { return w.focused and ti == w.active and w.tab().focused == p;}
/// How long a pane is lit for its bell.const bell_flash_ms = 150;
/// `p`, in tab `ti`, rang its bell: the sound and the flash, as the/// configuration asks, and if no one was looking at it, the pane rung —/// marked, notified, and the window asking for attention — once until it/// is looked at.fn ring(w: *Window, ti: usize, p: *Pane, now: std.Io.Timestamp) void { const own = w.app.configFor(p); const bell = own.bell; if (w.app.bell_sounds.ringSound(bell_sounds_mod.soundForPane(w.app.config(), own, p.profile)) == .desktop) w.pw.ringBell(); if (bell.flash and ti == w.active and !p.rect.empty()) { p.flash_until = now.addDuration(.fromMilliseconds(bell_flash_ms)); w.dirty = true; } if (w.looksAt(ti, p) or p.rung) return; p.rung = true; if (bell.mark) { w.showTitle(); w.dirty = true; } if (bell.notify) w.app.notifyBell(w, ti, p); if (bell.attention and !w.focused) w.pw.requestAttention();}
/// `p` is looked at: no longer rung.fn unring(w: *Window, p: *Pane) void { if (!p.rung) return; p.rung = false; w.showTitle(); w.dirty = true;}
/// The subtitle's line is on harrier's title bar: asked for, and the bar is/// harrier's to draw.fn showsSubtitle(w: *const Window) bool { return w.title_bar.bar.enabled and w.app.config().window.subtitle == .location;}
/// What the subtitle shows: the focused pane's location, in `buf`; null/// when it has none.fn subtitle(w: *Window, buf: []u8) ?TitleBar.Subtitle { if (!w.showsSubtitle()) return null; const l = w.tab().focused.location orelse return null; return .{ .text = location.format(buf, l, location.homeOf(w.app.env)), .host_len = l.host.len };}
/// A shaper made afresh from the configuration: for new font features, or/// a new family, whose faces the old one held.pub fn rebuildShaper(w: *Window) void { const config = w.app.config(); const fresh = frame.Builder.init(w.gpa, w.app.io, .{ .features = config.font.features, .ligatures = config.font.ligatures, .builtin_glyphs = config.font.builtin_glyphs, }) catch |err| { log.err("the shaper: {t}", .{err}); return; }; w.builder.deinit(); w.builder = fresh; for (w.tabs.items) |t| w.restale(t); w.dirty = true;}
/// What the configuration says about windows, applied to this one: the/// tab bar, the padding, and everything drawn again.pub fn applyConfig(w: *Window) void { const was_visible = w.tab_bar.visible(); w.tab_bar.mode = tabBarMode(w.app.config().window.tab_bar); w.applyTranslucency(); if (w.tab_bar.visible() != was_visible) w.barToggled() else w.layout(); for (w.tabs.items) |t| w.restale(t); // The profiles may have changed under the profile selector. if (w.profile_selector) |*p| p.filter(w.app.config().profiles) catch {}; w.showTitle(); w.dirty = true;}
/// The background's opacity, from the configuration, clamped.fn opacity(w: *const Window) f32 { return std.math.clamp(w.app.config().window.opacity, 0, 1);}
/// Tells the compositor whether the window is opaque, and whether to blur/// what is behind it.fn applyTranslucency(w: *Window) void { const translucent = w.opacity() < 1; w.pw.setOpaque(!translucent); const want_blur = translucent and w.app.config().window.blur; if (!w.pw.setBlur(want_blur) and want_blur) log.info("the compositor cannot blur behind the window", .{}); w.dirty = true;}
/// Every row of `t`'s panes to be shaped again.fn restale(_: *Window, t: *Tab) void { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| p.rows.stale = true;}
// ---------------------------------------------------------------------------// Splits// ---------------------------------------------------------------------------
/// Gives the focus to `p`, in the shown tab, telling both panes' programs/// that asked. A pane hidden behind a zoomed one brings the others back.pub fn focusPane(w: *Window, p: *Pane) void { const t = w.tab(); if (t.focused == p) return; if (w.focused) w.reportFocus(t.focused, false); t.focused = p; if (t.zoomed) { t.zoomed = false; w.layoutTab(t); } if (w.focused) w.reportFocus(p, true); w.cursor_on = true; w.showTitle(); w.dirty = true;}
/// Splits the focused pane: a new one beside it, or below it, in the same/// directory, with the focus.pub fn newSplit(w: *Window, axis: Tab.Axis) void { const t = w.tab(); const from = t.focused; var r: req.Request = .{ .cwd = from.workingDirectory(w.gpa, true) }; defer r.deinit(w.gpa); w.app.likeProfile(&r, from) catch {}; const p = Pane.create(w.app, &r, .{ .size = from.session.wanted }) catch |err| { log.err("new split: {t}", .{err}); return; }; t.split(from, axis, p) catch |err| { log.err("new split: {t}", .{err}); p.destroy(); return; }; if (w.focused) { w.reportFocus(from, false); w.reportFocus(p, true); } w.layoutTab(t); w.showTitle(); w.dirty = true;}
/// Closes pane `p` of tab `ti`, and the tab with its last pane. True when/// the tab went with it.pub fn closePane(w: *Window, ti: usize, p: *Pane) bool { const t = w.tabs.items[ti]; w.app.withdraw(p); if (w.link_hover) |h| if (h.pane == p) { w.link_hover = null; }; const was_focused = t.focused == p; if (p == w.press_pane) w.press_pane = null; if (w.scroll_drag) |d| if (d.pane == p) { w.scroll_drag = null; }; if (w.pane_drag) |d| if (d.pane == p or d.target == p) { w.pane_drag = null; }; if (t.remove(p)) { w.closeTab(ti); return true; } p.destroy(); w.divider_drag = null; w.layoutTab(t); if (ti == w.active) { if (was_focused and w.focused) w.reportFocus(t.focused, true); w.showTitle(); w.dirty = true; } return false;}
/// The focus to the pane next to the focused one, in `dir`.pub fn gotoPane(w: *Window, dir: Tab.Direction) void { if (w.tab().neighbor(w.pane(), dir)) |p| w.focusPane(p);}
/// The focus to the next pane, or the previous, in the tab's order,/// wrapping round.pub fn cyclePane(w: *Window, delta: i32) void { var buf: [Tab.max_panes]*Pane = undefined; const ps = w.tab().panes(&buf); const at = std.mem.findScalar(*Pane, ps, w.pane()).?; const n: i64 = @intCast(ps.len); w.focusPane(ps[@intCast(@mod(@as(i64, @intCast(at)) + delta, n))]);}
/// Moves the divider nearest the focused pane two cells along.pub fn resizeSplit(w: *Window, dir: Tab.Direction) void { const m = w.app.grid.metrics; const amount = 2 * switch (dir) { .left, .right => m.cell_width, .up, .down => m.cell_height, }; if (w.tab().resize(w.pane(), dir, amount)) w.layoutTab(w.tab());}
pub fn equalizeSplits(w: *Window) void { w.tab().equalize(); w.layoutTab(w.tab());}
/// The focused pane fills the tab, or no longer does.pub fn toggleZoom(w: *Window) void { const t = w.tab(); if (t.count() < 2 and !t.zoomed) return; t.zoomed = !t.zoomed; w.layoutTab(t);}
pub fn makeTarget(app: *App, pw: *platform.Window, width: u32, height: u32) !Target { return Target.init(app.device, pw, width, height, switch (app.config().present) { .auto => .auto, .dmabuf => .dmabuf, .shm => .shm, });}
pub fn destroy(w: *Window) void { for (w.tabs.items) |t| { w.app.forgetTab(t); t.destroy(); } w.tabs.deinit(w.gpa); w.builder.deinit(); w.target.deinit(); w.renderer.deinit(); w.pw.destroy(); if (w.preedit) |p| w.gpa.free(p); if (w.profile_selector) |*p| p.deinit(); w.gpa.destroy(w);}
// ---------------------------------------------------------------------------// Events from the platform// ---------------------------------------------------------------------------
pub fn handle(w: *Window, data: ev.Data) void { switch (data) { .close => w.closing = true, .menu => |m| if (m < App.window_menu.len) w.app.perform(w, App.window_menu[m][1]), .resize => |r| { w.scale = r.scale; w.width = @intFromFloat(@round(@as(f32, @floatFromInt(r.width)) * r.scale)); w.height = @intFromFloat(@round(@as(f32, @floatFromInt(r.height)) * r.scale)); w.title_bar.bar.enabled = r.draw_title_bar; w.title_bar.bar.state = r.state; w.app.useScale(r.scale); w.layout(); }, .frame => startup.markOnce(.first_frame, "first frame shown (compositor's frame callback)"), .focus => |f| { w.focused = f; if (f) w.app.last_window = w; if (f) _ = w.tab().focused.refreshLocation(); w.dirty = true; w.reportFocus(w.pane(), f); w.pw.setTextInput(if (f) w.cursorRect() else null); }, .key => |k| w.key(k), .text => |text| { if (w.profile_selector) |*pl| { pl.append(w.app.config().profiles, text) catch {}; w.dirty = true; return; } if (w.searchOpen(w.pane())) |search| { search.append(text) catch {}; w.dirty = true; return; } const s = w.pane().session; s.lock(); s.terminal.scrollViewport(.bottom); s.unlock(); s.write(text); }, .preedit => |p| { if (w.preedit) |old| w.gpa.free(old); w.preedit = if (p) |text| w.gpa.dupe(u8, text) catch null else null; w.dirty = true; }, .motion => |m| w.motion(m.x * w.scale, m.y * w.scale), .button => |b| w.button(b.button, b.pressed), .scroll => |s| w.scroll(s.dy), .local_drag => |p| { const slot = w.tab_bar.insertionAt(p.x * w.scale, p.y * w.scale); if (!std.meta.eql(slot, w.tab_bar.drop_slot)) { w.tab_bar.drop_slot = slot; w.dirty = true; } }, .local_drag_leave => if (w.tab_bar.drop_slot != null) { w.tab_bar.drop_slot = null; w.dirty = true; }, .local_drop => |p| w.app.dropTab(w, p.x * w.scale, p.y * w.scale), .local_drag_end => w.app.endTabDrag(), .drop_motion => |m| w.app.dnd.dropMotion(w, m), .drop_leave => w.app.dnd.dropLeave(w), .drop => |m| w.app.dnd.dropped(w, m), .drag_target, .drag_action, .drag_dropped, .drag_send => w.app.dnd.dragActivity(w, data), .activation_token => |t| w.app.openWithToken(t.cookie, t.token), // The program's, not a window's; see `App.run`. .hotkey, .hotkey_state => {}, .drag_finished => { w.app.dnd.dragActivity(w, data); // The compositor had the pointer, and the release went to it. w.left_down = false; w.press_pane = null; }, .pointer_leave => { if (w.title_bar.bar.leave()) { w.dirty = true; } if (w.tab_bar.hovered != null) { w.tab_bar.hovered = null; w.dirty = true; } w.hoverScrollbar(null); w.hoverHandle(null); }, }}
/// The grid that fits the window now, given to the session.pub fn layout(w: *Window) void { const m = w.app.grid.metrics; w.padding = @intFromFloat(@round(@as(f32, @floatFromInt(w.app.config().window.padding)) * w.scale)); w.title_bar.bar.scale = w.scale; w.title_bar.bar.width = w.width; w.title_bar.bar.height = w.height; // The title's family is made only for a window that draws its own. if (w.title_bar.bar.enabled) w.title_bar.lines(w.app.titleFamily(), w.app.config().window.subtitle == .location); w.title_bar.room(m.cell_width); w.title_bar.bar.native_frame = w.app.platform.drawsFrame(); w.pw.setMaximizeButton(w.title_bar.bar.maximizeButton()); const in = w.title_bar.bar.insets(); w.tab_bar.count = w.tabs.items.len; w.tab_bar.active = w.active; w.tab_bar.scale = w.scale; w.tab_bar.cell_height = m.cell_height; w.tab_bar.cell_width = m.cell_width; w.tab_bar.x = in.left; w.tab_bar.y = in.top; w.tab_bar.width = w.width -| in.left -| in.right; const bar = w.tab_bar.height(); // Never smaller than the frame and a cell or two of terminal. const min_w = in.left + in.right + 2 * w.padding + 10 * m.cell_width; const min_h = in.top + bar + in.bottom + 2 * w.padding + 2 * m.cell_height; w.pw.setMinSize( @intFromFloat(@ceil(@as(f32, @floatFromInt(min_w)) / w.scale)), @intFromFloat(@ceil(@as(f32, @floatFromInt(min_h)) / w.scale)), ); w.content = .{ .x = in.left, .y = in.top + bar, .w = w.width -| in.left -| in.right, .h = w.height -| in.top -| bar -| in.bottom, }; // Every tab, the hidden ones too, so that a program is never drawn at // a size it was not told about. for (w.tabs.items) |t| w.layoutTab(t); w.dirty = true;}
/// Lays `t`'s panes out in the window, and tells each its grid.fn layoutTab(w: *Window, t: *Tab) void { if (w.content.empty()) return; const m = w.app.grid.metrics; t.layout(w.content, w.gap(), 2 * w.padding + 4 * m.cell_width, 2 * w.padding + 2 * m.cell_height); var buf: [Tab.max_panes]*Pane = undefined; if (t == w.tab()) w.snapToCells(t.panes(&buf).len == 1 or t.zoomed); for (t.panes(&buf)) |p| { // A pane hidden by a zoomed one keeps its size until it is shown. if (p.rect.empty()) continue; const size = w.gridOf(p.rect); const was = p.session.wanted; p.session.resize(size, m.cell_width, m.cell_height); // Rows are shaped again when they change; one that only moved, // with its neighbour's divider, need not be. if (size.cols != was.cols or size.rows != was.rows) { p.rows.stale = true; if (p.drawn and w.app.config().window.resize_overlay != .never) w.showSize(p); } } w.dirty = true;}
/// Has a resize stop at whole cells, so that no strip of one is left/// empty along the right and bottom edges: when the shown tab is one pane/// (`whole`), whose grid is the window less its frame, bars and padding./// Split, each pane would leave its own strip, and none is avoided.fn snapToCells(w: *Window, whole: bool) void { if (!whole) return w.pw.setSnap(null); const m = w.app.grid.metrics; const in = w.title_bar.bar.insets(); w.pw.setSnap(.{ .base_width = in.left + in.right + 2 * w.padding, .base_height = in.top + in.bottom + w.tab_bar.height() + 2 * w.padding, .step_width = m.cell_width, .step_height = m.cell_height, });}
/// Shows `p`'s grid size over it for a moment, from now.fn showSize(w: *Window, p: *Pane) void { const ms = w.app.config().window.resize_overlay_ms; if (ms == 0) return; p.size_shown_until = std.Io.Clock.awake.now(w.app.io).addDuration(.fromMilliseconds(ms)); w.dirty = true;}
/// Shows `e` over `p` for a moment, from now, in place of any toast it/// has; unless the configuration says not to.fn toast(w: *Window, p: *Pane, e: Toast.Event) void { if (e.optional()) switch (w.app.configFor(p).clipboard.toast) { .always => {}, .programs => if (e.who == .user) return, .never => return, }; const ms = w.app.config().window.toast_ms; if (ms == 0) return; p.toast = .init(e, std.Io.Clock.awake.now(w.app.io).addDuration(.fromMilliseconds(ms))); w.dirty = true;}
/// What a toast calls `p`'s program, in `buf`: its foreground program as/// it is now, else as it was last seen, else what the pane started; null/// for a pane with no program, a serial port's or a message's.fn programName(w: *Window, p: *Pane, buf: []u8) ?[]const u8 { const pty = switch (p.session.backend) { .pty => |*pty| pty, else => return null, }; if (comptime foreground.supported) live: { const pgrp = foreground.group(pty.handle()) orelse break :live; const id = foreground.identity(pgrp) orelse break :live; const fg = (foreground.describe(w.gpa, &id) catch break :live) orelse break :live; defer foreground.free(w.gpa, fg); const n = @min(fg.name.len, buf.len); @memcpy(buf[0..n], fg.name[0..n]); return buf[0..n]; } if (p.foreground) |fg| { const n = @min(fg.name.len, buf.len); @memcpy(buf[0..n], fg.name[0..n]); return buf[0..n]; } var bw: std.Io.Writer = .fixed(buf); pty.describe(&bw) catch {}; return if (bw.end > 0) bw.buffered() else null;}
/// When the window next has something to change on its own, if it does:/// a size overlay to take away, a progress bar its program has stopped/// updating, or the next step of one sweeping. The main loop wakes for it.pub fn nextDeadline(w: *const Window) ?std.Io.Timestamp { // A window whose last tab has just gone has nothing to show. if (w.tabs.items.len == 0) return null; var buf: [Tab.max_panes]*Pane = undefined; var next: ?std.Io.Timestamp = null; const earliest = struct { fn f(n: *?std.Io.Timestamp, t: std.Io.Timestamp) void { if (n.* == null or t.compare(.lt, n.*.?)) n.* = t; } }.f; for (w.tab().panes(&buf)) |p| { if (p.size_shown_until) |u| earliest(&next, u); if (p.flash_until) |u| earliest(&next, u); if (p.toast) |t| earliest(&next, t.until); // An animated image's next frame, on a pane that is shown. if (!p.rect.empty()) if (p.images.next_frame) |f| earliest(&next, f); } // Every tab's: a hidden tab's progress is on the tab bar. for (w.tabs.items) |t| for (t.panes(&buf)) |p| if (p.progress) |pr| { if (pr.expires) earliest(&next, pr.at.addDuration(.fromMilliseconds(Pane.progress_timeout_ms))); }; if (w.sweeping()) earliest(&next, w.sweep_at); if (w.checksLocation()) earliest(&next, w.location_check_at); if (w.autoscroll_at) |a| earliest(&next, a); // A search still going: as soon as can be. if (w.searching()) earliest(&next, std.Io.Clock.awake.now(w.app.io)); return next;}
/// Whether a progress bar is shown sweeping: one with no amount, on a pane/// that is shown, or along a tab in a shown tab bar.fn sweeping(w: *const Window) bool { if (!w.app.config().window.progress_bar) return false; const bar = w.tab_bar.visible(); var buf: [Tab.max_panes]*Pane = undefined; for (w.tabs.items, 0..) |t, ti| { if (bar) if (tabProgress(t)) |pr| if (sweeps(pr)) return true; if (ti != w.active) continue; for (t.panes(&buf)) |p| if (p.progress) |pr| { if (sweeps(pr) and !p.rect.empty()) return true; }; } return false;}
/// Whether a progress bar sweeps: going, with no amount to show.fn sweeps(pr: Pane.Progress) bool { return pr.state == .normal and pr.percent == null;}
/// The progress a tab shows on the tab bar: an error in any of its panes/// first, then a pane waiting on the user, then its focused pane's, then/// any other's.fn tabProgress(t: *Tab) ?Pane.Progress { var buf: [Tab.max_panes]*Pane = undefined; const ps = t.panes(&buf); for (ps) |p| if (p.progress) |pr| if (pr.state == .@"error") return pr; for (ps) |p| if (p.progress) |pr| if (pr.state == .blocked) return pr; if (t.focused.progress) |pr| return pr; for (ps) |p| if (p.progress) |pr| return pr; return null;}
/// How often a sweeping progress bar is drawn again.const sweep_ms = 33;
/// How often the focused pane's location is looked for in `/proc`.const location_check_ms = 1000;
/// How long a selection dragged `rows` cell heights past its pane's top/// or bottom waits to scroll the pane its next row: slowly just past the/// edge, so that it can be stopped on a line, and faster further out,/// for a long scrollback. The rate rises evenly, to Ghostty's one row in/// 15 ms five rows out, and no faster beyond.fn autoscrollInterval(rows: f32) std.Io.Duration { const slowest = 1000.0 / 100.0; const fastest = 1000.0 / 15.0; const reach = 5.0; const per_second = slowest + (fastest - slowest) * std.math.clamp(rows / reach, 0, 1); return .fromMilliseconds(@intFromFloat(@round(1000.0 / per_second)));}
test "a selection dragged further past the edge scrolls faster, to a limit" { try std.testing.expectEqual(100, autoscrollInterval(0).toMilliseconds()); try std.testing.expectEqual(100, autoscrollInterval(-1).toMilliseconds()); try std.testing.expectEqual(26, autoscrollInterval(2.5).toMilliseconds()); try std.testing.expectEqual(15, autoscrollInterval(5).toMilliseconds()); try std.testing.expectEqual(15, autoscrollInterval(50).toMilliseconds()); var last: i64 = 100; for (1..50) |i| { const ms = autoscrollInterval(@as(f32, @floatFromInt(i)) / 10).toMilliseconds(); try std.testing.expect(ms <= last); last = ms; }}
/// Whether the focused pane's location is looked for in `/proc` from time/// to time: on Linux, while the subtitle shows it, this window has the/// focus, and its shell has not said where it is with OSC 7.fn checksLocation(w: *const Window) bool { if (builtin.os.tag != .linux or !w.focused or !w.showsSubtitle()) return false; const p = w.tab().focused; return !p.location_reported and p.session.backend == .pty;}
/// The width of a divider between panes: a line.fn gap(w: *const Window) u32 { return @max(1, @as(u32, @intFromFloat(@round(w.scale))));}
/// The grid that fits `r`, inside its padding.fn gridOf(w: *const Window, r: Pane.Rect) Backend.Size { const m = w.app.grid.metrics; const inner_w = r.w -| 2 * w.padding; const inner_h = r.h -| 2 * w.padding; return .{ .cols = @intCast(std.math.clamp(inner_w / m.cell_width, 1, std.math.maxInt(u16))), .rows = @intCast(std.math.clamp(inner_h / m.cell_height, 1, std.math.maxInt(u16))), .width_px = @intCast(@min(r.w, std.math.maxInt(u16))), .height_px = @intCast(@min(r.h, std.math.maxInt(u16))), };}
fn key(w: *Window, k: ev.Key) void { // Whose key it is decides what it is bound to: a program that has just // been started has its bindings from the first key. if (w.app.checkForeground(w.pane(), false)) w.dirty = true; const bound = w.app.bindingsFor(w.pane()); // The profile selector, while it is open, takes every key but the one that // closes it. if (w.profile_selector != null) { if (bindings.find(bound, k)) |action| if (action == .toggle_profile_selector) return w.closeProfileSelector(); return w.profileSelectorKey(k); } if (w.pane().confirmation != null) return w.confirmationKey(k); if (bindings.find(bound, k)) |action| { w.app.perform(w, action); return; } // The search field, while it is open, takes the typing. if (w.searchOpen(w.pane()) != null) { w.searchKey(w.pane(), k); return; } // Typing to the program: a match moved to with the field closed has // done its job. if (k.action == .press) if (w.pane().search) |*s| if (s.show_selected) { s.show_selected = false; w.dirty = true; }; if (w.pane().held) { // Any key that is not just a modifier: one with text, or Return, // Escape or keypad Enter. if (k.action == .press and (k.text.len > 0 or k.keysym == 0xff0d or k.keysym == 0xff1b or k.keysym == 0xff8d)) w.pane().closing = true; return; } if (k.action == .release and k.text.len == 0) { // Releases matter only to the kitty keyboard protocol, and the // encoder says nothing for them otherwise. } var buf: [128]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); const s = w.pane().session; { s.lock(); defer s.unlock(); keys.encode(&s.terminal, k, &out) catch {}; if (out.end > 0 and k.action != .release) { s.terminal.scrollViewport(.bottom); w.cursor_on = true; } } if (out.end > 0) s.write(out.buffered());}
// ---------------------------------------------------------------------------// The profile selector// ---------------------------------------------------------------------------
/// Opens the profile selector, or closes it.pub fn toggleProfileSelector(w: *Window) void { if (w.profile_selector != null) return w.closeProfileSelector(); w.profile_selector = ProfileSelector.init(w.gpa, w.app.config().profiles) catch |err| { log.err("the profile selector: {t}", .{err}); return; }; w.profile_selector_hover = null; w.profile_selector_scroll_rest = 0; w.dirty = true;}
fn closeProfileSelector(w: *Window) void { var p = w.profile_selector orelse return; p.deinit(); w.profile_selector = null; w.profile_selector_box = .{}; w.profile_selector_list = .{}; w.dirty = true;}
/// Opens the profile selector's selected profile, where it says or, with/// `flip`, in the other place, and closes the profile selector.fn runProfileSelector(w: *Window, flip: bool) void { const p = &(w.profile_selector orelse return); const profiles = w.app.config().profiles; const i = p.current() orelse return; w.closeProfileSelector(); if (i < profiles.len) w.app.launchProfile(w, &profiles[i], flip);}
/// A key typed while the profile selector is open: text narrows it, the arrows/// move through it, Enter opens what is selected and Escape closes it.fn profileSelectorKey(w: *Window, k: ev.Key) void { if (k.action == .release) return; const p = &w.profile_selector.?; const profiles = w.app.config().profiles; switch (k.keysym) { 0xff1b => return w.closeProfileSelector(), // Return and keypad Enter; with shift, in the other place. 0xff0d, 0xff8d => return w.runProfileSelector(k.mods.shift), 0xff52 => p.move(-1), 0xff54 => p.move(1), 0xff55 => p.move(-ProfileSelector.rows_shown), 0xff56 => p.move(ProfileSelector.rows_shown), 0xff08 => p.backspace(profiles) catch {}, else => { if (k.mods.ctrl or k.mods.alt or k.mods.super) { // ctrl+p and ctrl+n move, as in a shell's history; ctrl+u // clears, as a shell's line does. if (k.mods.ctrl and !k.mods.alt and !k.mods.super) switch (k.unshifted) { 'p' => p.move(-1), 'n' => p.move(1), 'u' => p.clear(profiles) catch {}, else => {}, }; } else if (k.text.len > 0 and k.text[0] >= 0x20 and k.text[0] != 0x7f) { p.append(profiles, k.text) catch {}; } }, } w.dirty = true;}
/// Which of the profile selector's shown rows is at `x`, `y`.fn profileSelectorRowAt(w: *const Window, x: f32, y: f32) ?usize { const l = w.profile_selector_list; if (!l.contains(x, y)) return null; return @intFromFloat((y - @as(f32, @floatFromInt(l.y))) / @as(f32, @floatFromInt(@max(w.profile_selector_row_h, 1))));}
/// The pointer moved with the profile selector open: true when it is over/// it, which nothing beneath it then sees.fn profileSelectorMotion(w: *Window, x: f32, y: f32) bool { if (w.profile_selector == null) return false; const row = w.profileSelectorRowAt(x, y); if (!std.meta.eql(row, w.profile_selector_hover)) { w.profile_selector_hover = row; w.dirty = true; } if (!w.profile_selector_box.contains(x, y)) return false; w.setPointerShape(if (row != null) .pointer else .default); return true;}
/// A press or release with the profile selector open: a row is opened by a/// left press on it, with shift in the other place, the middle button/// pastes, and any other press over the terminal closes the profile/// selector. False when the profile selector has nothing to do with it — a/// press on the title bar or the tab bar.fn profileSelectorButton(w: *Window, b: ev.MouseButton, pressed: bool) bool { if (w.profile_selector == null) return false; const x = w.pointer_x; const y = w.pointer_y; // The middle button pastes into its field, wherever it is pressed. if (b == .middle and (w.profile_selector_box.contains(x, y) or w.content.contains(x, y))) { if (pressed) w.paste(.primary); return true; } if (w.profile_selector_box.contains(x, y)) { if (!pressed or b != .left) return true; const row = w.profileSelectorRowAt(x, y) orelse return true; if (w.profile_selector.?.pick(row)) w.runProfileSelector(w.app.platform.mods.shift); return true; } if (!w.content.contains(x, y)) return false; if (pressed) w.closeProfileSelector(); return true;}
/// Scrolling with the profile selector open and the pointer over it scrolls its/// rows.fn profileSelectorScroll(w: *Window, dy: f32) bool { const p = &(w.profile_selector orelse return false); if (!w.profile_selector_box.contains(w.pointer_x, w.pointer_y)) return false; w.profile_selector_scroll_rest += dy; const rows: i32 = @intFromFloat(w.profile_selector_scroll_rest); if (rows == 0) return true; w.profile_selector_scroll_rest -= @floatFromInt(rows); p.scrollBy(rows); w.profile_selector_hover = w.profileSelectorRowAt(w.pointer_x, w.pointer_y); w.dirty = true; return true;}
/// The profile selector, centered near the top of the terminal: the field,/// and under it the profiles matching it, each with what it runs.fn buildProfileSelector(w: *Window, colors: decor.Colors) !void { w.profile_selector_box = .{}; w.profile_selector_list = .{}; const p = &(w.profile_selector orelse return); const profiles = w.app.config().profiles; const m = w.app.grid.metrics; const cw = m.cell_width; const ch = m.cell_height; const fg = colors.foreground; const bg = colors.background; const dim = decor.mix(bg, fg, 0.55); const edge = decor.mix(bg, fg, 0.35); const fill = decor.mix(bg, fg, 0.08); const lit = decor.mix(bg, fg, 0.20); const hover = decor.mix(bg, fg, 0.13); const one: u32 = @max(1, @as(u32, @intFromFloat(@round(w.scale)))); const pad = cw; const c = w.content;
const max_cells = (c.w -| 2 * pad -| 2 * pad) / @max(cw, 1); if (max_cells < 8) return; const cells: u32 = @min(64, max_cells); const row_h = ch + ch / 3; const field_h = ch + ch / 2; const shown = @min(p.matches.items.len -| p.scroll, ProfileSelector.rows_shown); // A line saying why there is nothing, when there is nothing. const lines: u32 = if (shown == 0) 1 else @intCast(shown); const box_w = cells * cw + 2 * pad; const box_h = field_h + one + lines * row_h + pad / 2; if (c.h < box_h + ch) return; const x = c.x + (c.w - box_w) / 2; const y = c.y + @min(c.h / 8, 3 * ch); w.profile_selector_box = .{ .x = x, .y = y, .w = box_w, .h = box_h }; try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 });
// The field: what has been typed, its end in view, and a cursor. var buf: [256]u21 = undefined; const ty = y + (field_h - ch) / 2; const cursor_w: f32 = @floatFromInt(@max(2 * one, cw / 6)); if (p.query.items.len == 0) { const ph = decor.fitTitle(l10n.text("profile-selector-placeholder"), cells -| 1, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad + cw), @floatFromInt(ty), ph, .{ dim.r, dim.g, dim.b, 255 }); try w.builder.rect(@floatFromInt(x + pad), @floatFromInt(ty), cursor_w, @floatFromInt(ch), .{ fg.r, fg.g, fg.b, 255 }); } else { const typed = decor.fitEnd(p.query.items, cells -| 1, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ty), typed, .{ fg.r, fg.g, fg.b, 255 }); const cursor_x = x + pad + @as(u32, @intCast(decor.cells(typed))) * cw; try w.builder.rect(@floatFromInt(cursor_x), @floatFromInt(ty), cursor_w, @floatFromInt(ch), .{ fg.r, fg.g, fg.b, 255 }); } try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + field_h), @floatFromInt(box_w - 2 * one), @floatFromInt(one), .{ edge.r, edge.g, edge.b, 255 });
const list_y = y + field_h + one; if (shown == 0) { const why = l10n.text(if (profiles.len == 0) "profile-selector-empty" else "profile-selector-no-matches"); const cps = decor.fitTitle(why, cells, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(list_y + (row_h - ch) / 2), cps, .{ dim.r, dim.g, dim.b, 255 }); return; } w.profile_selector_list = .{ .x = x + one, .y = list_y, .w = box_w - 2 * one, .h = @as(u32, @intCast(shown)) * row_h }; w.profile_selector_row_h = row_h; for (0..shown) |row| { const i = p.scroll + row; const e = &profiles[p.matches.items[i]]; const ry = list_y + @as(u32, @intCast(row)) * row_h; const selected = i == p.selected; const hovered = if (w.profile_selector_hover) |h| h == row else false; if (selected or hovered) { const f = if (selected) lit else hover; try w.builder.rect(@floatFromInt(x + one), @floatFromInt(ry), @floatFromInt(box_w - 2 * one), @floatFromInt(row_h), .{ f.r, f.g, f.b, 255 }); } const rty = ry + (row_h - ch) / 2; // The name, then what it runs, dimmed, in what the name leaves; the // letters what was typed was found by underlined in each. var dbuf: [512]u8 = undefined; const described = ProfileSelector.describe(e, &dbuf); var name_at: [256]usize = undefined; var what_at: [256]usize = undefined; const at = p.found(e.name, described, &name_at, &what_at); const name = decor.fitTitle(e.name, cells, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(rty), name, .{ fg.r, fg.g, fg.b, 255 }); try w.underlineFound(e.name, name, at.name, x + pad, rty, fg); const left = cells -| @as(u32, @intCast(decor.cells(name))) -| 2; if (left < 4) continue; const what = decor.fitTitle(described, left, &buf); const dx = x + pad + (@as(u32, @intCast(decor.cells(name))) + 2) * cw; try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(dx), @floatFromInt(rty), what, .{ dim.r, dim.g, dim.b, 255 }); try w.underlineFound(described, what, at.what, dx, rty, dim); }}
/// Underlines, in `color`, the letters of `text` at the byte offsets `at`,/// where `shown`, `text` fitted by `decor.fitTitle`, was drawn at `x`, `y`./// A letter an ellipsis took the place of is not underlined.fn underlineFound(w: *Window, text: []const u8, shown: []const u21, at: []const usize, x: u32, y: u32, color: vt.color.RGB) !void { const m = w.app.grid.metrics; const whole = std.unicode.utf8CountCodepoints(text) catch text.len; // All of it, or what is left before the ellipsis. const kept = if (shown.len >= whole) shown.len else shown.len -| 1; for (at) |off| { if (off >= text.len) continue; // The code point the byte begins: the lead bytes before it. var ci: usize = 0; for (text[0..off]) |b| { if (b & 0xc0 != 0x80) ci += 1; } if (ci >= kept) continue; const cx = x + @as(u32, @intCast(decor.cells(shown[0..ci]))) * m.cell_width; const width = @max(1, decor.cells(shown[ci .. ci + 1])); try w.builder.rect( @floatFromInt(cx), @floatFromInt(y + m.underline_top), @floatFromInt(@as(u32, @intCast(width)) * m.cell_width), @floatFromInt(m.underline_thickness), .{ color.r, color.g, color.b, 255 }, ); }}
// ---------------------------------------------------------------------------// Confirmations// ---------------------------------------------------------------------------
/// Asks the user `q` about `p`, over it. A question already waiting there/// is answered no, or `q` is, as `Confirmation.replaces` says. Asked/// unseen, the pane's tab and window are marked, and the window asks for/// attention. Takes `q`.fn ask(w: *Window, p: *Pane, q: Confirmation.Question) void { if (p.confirmation) |*old| { if (!Confirmation.replaces(old.question, q)) return w.answerWith(p, q, .no); const was = old.question; old.layout.deinit(w.gpa); p.confirmation = null; w.answerWith(p, was, .no); } p.confirmation = .init(q); w.dirty = true; const ti = for (w.tabs.items, 0..) |t, i| { var buf: [Tab.max_panes]*Pane = undefined; if (std.mem.findScalar(*Pane, t.panes(&buf), p) != null) break i; } else return; if (w.looksAt(ti, p) or p.asking) return; p.asking = true; w.showTitle(); if (!w.focused) w.pw.requestAttention();}
/// `p` is looked at: its question, if it has one, is seen.fn unask(w: *Window, p: *Pane) void { if (!p.asking) return; p.asking = false; w.showTitle(); w.dirty = true;}
/// Answers `p`'s waiting question with `a`.fn answer(w: *Window, p: *Pane, a: Confirmation.Answer) void { var c = p.confirmation orelse return; c.layout.deinit(w.gpa); p.confirmation = null; w.unask(p); w.dirty = true; w.answerWith(p, c.question, a);}
/// Does what answering `q` with `a` says. Takes `q`.fn answerWith(w: *Window, p: *Pane, q: Confirmation.Question, a: Confirmation.Answer) void { defer q.deinit(w.gpa); switch (q) { .paste => |x| if (a != .no) sendPaste(p, x.text, x.from), .write => |x| { if (a == .yes_for_pane) p.clipboard_allowed.write = true; w.answerWrite(p, x.clipboard, x.contents, &x.reply, a != .no, true); }, .read => |x| { if (a == .yes_for_pane) p.clipboard_allowed.read = true; w.answerRead(p, x.clipboard, &x.reply, a != .no); }, }}
/// Answers a program's clipboard write: puts it on the clipboard, when/// `allowed`, and says how that went; and otherwise refuses it. A toast/// says so when it fails, or when it was not `asked` about: one that was,/// the user knows of.fn answerWrite(w: *Window, p: *Pane, which: Confirmation.Clipboard, contents: []const vt.clipboard.Content, reply: *const vt.clipboard.Write.Deferred, allowed: bool, asked: bool) void { if (!allowed) return p.session.answerClipboardWrite(reply, .denied); var name_buf: [64]u8 = undefined; w.setClipboard(platformClipboard(which), contents) catch |err| { log.warn("a program's clipboard write: {t}", .{err}); w.toast(p, .{ .who = .{ .program = w.programName(p, &name_buf) }, .what = .failed, .clipboard = which }); return p.session.answerClipboardWrite(reply, switch (err) { error.Unsupported => .unsupported, else => .io_error, }); }; if (!asked) w.toast(p, .{ .who = .{ .program = w.programName(p, &name_buf) }, .what = .copied, .clipboard = which, .contents = contents }); p.session.answerClipboardWrite(reply, .{ .success = .{} });}
/// Answers a program's clipboard read: with what is on the clipboard now,/// once it has come, when `allowed`, and otherwise as a refusal.fn answerRead(w: *Window, p: *Pane, which: Confirmation.Clipboard, reply: *const vt.clipboard.Read.Deferred, allowed: bool) void { if (!allowed) return p.session.answerClipboardRead(reply, .denied); w.app.clipboard.read(p, platformClipboard(which), reply);}
fn platformClipboard(c: Confirmation.Clipboard) ev.Clipboard { return switch (c) { .standard => .standard, .primary => .primary, };}
/// A key typed while the focused pane is asking: Enter answers with the/// selected answer and Escape with the one that does nothing, Left, Right/// and Tab select, and Up, Down, PageUp and PageDown scroll the text. Nothing reaches the program meanwhile, a paste/// included, but any other binding still works, so that the user can go/// elsewhere and come back.fn confirmationKey(w: *Window, k: ev.Key) void { const p = w.pane(); const c = &p.confirmation.?; if (k.action == .release) return; switch (k.keysym) { 0xff1b => return w.answer(p, .no), // Return and keypad Enter. 0xff0d, 0xff8d => return w.answer(p, c.current()), // Left, and shift+Tab. 0xff51, 0xfe20 => c.move(-1), // Right, Tab. 0xff53, 0xff09 => c.move(if (k.mods.shift) -1 else 1), // Up, Down, PageUp, PageDown. 0xff52 => c.scrollBy(-1), 0xff54 => c.scrollBy(1), 0xff55 => c.scrollBy(-@as(isize, @intCast(@max(c.shown_rows, 1)))), 0xff56 => c.scrollBy(@intCast(@max(c.shown_rows, 1))), else => { if (bindings.find(w.app.bindingsFor(p), k)) |action| switch (action) { // Pasting is the program's; it waits. .paste, .paste_selection => {}, else => w.app.perform(w, action), }; return; }, } w.dirty = true;}
/// The shown pane whose confirmation the pointer is over, if one is.fn confirmationAt(w: *Window, x: f32, y: f32) ?*Pane { var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| if (p.confirmation) |*c| if (c.box.contains(x, y)) return p; return null;}
/// The pointer moved: over a confirmation, its answers light up, and/// nothing beneath it sees the pointer.fn confirmationMotion(w: *Window, x: f32, y: f32) bool { var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| if (p.confirmation) |*c| { const hover: ?usize = for (c.buttons[0..c.answers().len], 0..) |r, i| { if (r.contains(x, y)) break i; } else null; if (!std.meta.eql(hover, c.hover)) { c.hover = hover; w.dirty = true; } }; const p = w.confirmationAt(x, y) orelse return false; w.setPointerShape(if (p.confirmation.?.hover != null) .pointer else .default); return true;}
/// The wheel over a confirmation scrolls its text, three rows a notch as a/// pane's scrollback does; whether it was over one.fn confirmationScroll(w: *Window, dy: f32) bool { const p = w.confirmationAt(w.pointer_x, w.pointer_y) orelse return false; const c = &p.confirmation.?; c.scroll_rest += dy; const rows: i32 = @intFromFloat(c.scroll_rest); if (rows == 0) return true; c.scroll_rest -= @floatFromInt(rows); c.scrollBy(rows * 3); w.dirty = true; return true;}
/// A press over a confirmation: a left press on an answer gives it, and/// anything else over it is ignored.fn confirmationButton(w: *Window, b: ev.MouseButton, pressed: bool) bool { const p = w.confirmationAt(w.pointer_x, w.pointer_y) orelse return false; if (!pressed or b != .left) return true; const c = &p.confirmation.?; for (c.buttons[0..c.answers().len], c.answers()) |r, a| { if (r.contains(w.pointer_x, w.pointer_y)) { w.answer(p, a); return true; } } return true;}
/// What a confirmation's answer is labeled.fn answerLabel(q: Confirmation.Question, a: Confirmation.Answer) []const u8 { return l10n.text(switch (a) { .yes => if (q == .paste) "confirm-paste" else "confirm-allow", .yes_for_pane => "confirm-allow-pane", .no => if (q == .paste) "confirm-cancel" else "confirm-deny", });}
/// `p`'s confirmation, centered near the top of it: the question, what/// would be pasted or written, and the answers along the bottom./// The most cell rows a confirmation's image is shown in.const max_preview_rows = 8;
/// The image `p`'s confirmation shows, if it shows one.fn previewOf(p: *const Pane) ?*const Confirmation.Preview { const c = &(p.confirmation orelse return null); return switch (c.question) { .write => |*x| if (x.preview) |*pv| pv else null, else => null, };}
/// Where a renderer holds the image `p`'s confirmation shows.fn previewKey(p: *const Pane) images.Key { return .{ .pane = p.id, .screen = .confirmation, .image = 0 };}
fn buildConfirmation(w: *Window, p: *Pane) !void { const c = &(p.confirmation orelse return); c.box = .{}; c.buttons = @splat(.{}); const m = w.app.grid.metrics; const cw = m.cell_width; const ch = m.cell_height; const fg = p.state.colors.foreground; const bg = p.state.colors.background; const dim = decor.mix(bg, fg, 0.55); const edge = decor.mix(bg, fg, 0.35); const fill = decor.mix(bg, fg, 0.08); const lit = decor.mix(bg, fg, 0.25); const hover = decor.mix(bg, fg, 0.15); const one: u32 = @max(1, @as(u32, @intFromFloat(@round(w.scale)))); const pad = cw; const r = p.rect;
const max_cells = (r.w -| 4 * pad) / @max(cw, 1); if (max_cells < 20) return; // The text, laid out as wide as the box may be, less a cell for its // scrollbar; the box is then as wide as the widest row needs, and // never narrower than 64 cells, which the question and the answers // have always had. Typed: @min with 64 would narrow it to a u7, which // arithmetic with it overflows in a pane 64 cells wide. const text = c.shownText(); const text_cells: u32 = @min(Confirmation.max_text_cells, max_cells) - 1; if (text.len > 0) c.layout.update(w.gpa, text, text_cells) catch { c.layout.deinit(w.gpa); }; const has_text = c.layout.len() > 0; const narrowest: u32 = @min(64, max_cells); const cells: u32 = if (has_text) @min(text_cells + 1, @max(narrowest, c.layout.widest + 1)) else narrowest;
// The question. var head_buf: [512]u8 = undefined; var head_w: std.Io.Writer = .fixed(&head_buf); const which = switch (c.question) { .paste => "standard", .write => |x| @tagName(x.clipboard), .read => |x| @tagName(x.clipboard), }; const host = if (p.location) |l| l.host else ""; const id = switch (c.question) { .paste => "confirm-paste-question", .write => if (host.len > 0) "confirm-write-question-remote" else "confirm-write-question", .read => if (host.len > 0) "confirm-read-question-remote" else "confirm-read-question", }; l10n.write(&head_w, id, &.{ l10n.str("pane", p.title.items), l10n.str("host", host), l10n.str("which", which) }) catch {}; var head_cps: [256]u21 = undefined; const head = decor.fitTitle(head_w.buffered(), cells, &head_cps);
// Why, for a paste. var why_cps: [256]u21 = undefined; const why: []const u21 = if (c.question == .paste) decor.fitTitle(l10n.text("confirm-paste-why"), cells, &why_cps) else &.{}; // Which Clipboard types, when any is not text: a write's, with their // sizes, and those a read asks for. var types_buf: [512]u8 = undefined; var types_w: std.Io.Writer = .fixed(&types_buf); switch (c.question) { .paste => {}, .write => |x| { var mimes: [vt.kitty.clipboard.max_write_mimes][]const u8 = undefined; const n = @min(x.contents.len, mimes.len); for (x.contents[0..n], mimes[0..n]) |y, *slot| slot.* = y.mime; if (Confirmation.namesTypes(mimes[0..n])) for (x.contents[0..n], 0..) |y, i| { if (i > 0) types_w.writeAll(" · ") catch {}; var size_buf: [32]u8 = undefined; l10n.write(&types_w, "confirm-type-size", &.{ l10n.str("type", y.mime), l10n.str("size", Confirmation.formatSize(y.data.len, &size_buf)) }) catch {}; }; }, .read => |x| if (x.reply.kitty) |*k| { var mimes: [vt.kitty.clipboard.max_read_mimes][]const u8 = undefined; for (0..k.mimes_len) |i| mimes[i] = k.mime(i); if (Confirmation.namesTypes(mimes[0..k.mimes_len])) for (mimes[0..k.mimes_len], 0..) |mime, i| { if (i > 0) types_w.writeAll(" · ") catch {}; types_w.writeAll(mime) catch {}; }; }, } var types_cps: [512]u21 = undefined; const types = decor.fitTitle(types_w.buffered(), cells, &types_cps); // How much text there is, over the well it is shown in. var size_label_buf: [128]u8 = undefined; var size_label_w: std.Io.Writer = .fixed(&size_label_buf); if (has_text) { var lines_buf: [64]u8 = undefined; var lines_w: std.Io.Writer = .fixed(&lines_buf); l10n.write(&lines_w, "toast-lines", &.{l10n.num("count", Toast.count(text).lines)}) catch {}; var size_buf: [32]u8 = undefined; l10n.write(&size_label_w, "confirm-text-size", &.{ l10n.str("lines", lines_w.buffered()), l10n.str("size", Confirmation.formatSize(text.len, &size_buf)) }) catch {}; } var size_label_cps: [128]u21 = undefined; const size_label = decor.fitTitle(size_label_w.buffered(), cells, &size_label_cps);
const row_h = ch + ch / 3; const button_h = ch + ch / 2; const lines: u32 = 1 + @as(u32, if (why.len > 0) 1 else 0) + @as(u32, if (types.len > 0) 1 else 0) + @as(u32, if (has_text) 1 else 0); const box_w = cells * cw + 2 * pad; const text_h = pad / 2 + lines * row_h + pad / 2 + button_h + pad / 2; // The well's own room around its rows. const well_pad = pad / 2; const well_min = if (has_text) ch + 2 * well_pad + pad / 2 else 0; const y = r.y + @min(r.h / 8, 3 * ch); // Down to a cell above the pane's bottom. const room = (r.y + r.h) -| y -| ch; if (room < text_h + well_min) return; // The image, as large as fits, in what is left of the pane once the // well has a row: at most eight rows, and none when that would be less // than two. const pv = previewOf(p); const shown: Confirmation.Rect = if (pv) |v| shown: { const max_h = @min(max_preview_rows * ch, (room - text_h - well_min) -| pad / 2); if (max_h < 2 * ch) break :shown .{}; const size = Confirmation.fit(v.width, v.height, cells * cw, max_h); break :shown .{ .w = size.w, .h = size.h }; } else .{}; const image_h = if (shown.h > 0) shown.h + pad / 2 else 0; // The well: as many rows as there are, or as fit. const fit_rows = if (has_text) (room - text_h - image_h - (well_min - ch)) / ch else 0; const n_rows: usize = @min(c.layout.len(), fit_rows); c.shown_rows = n_rows; c.scrollBy(0); const well_h: u32 = if (has_text) @as(u32, @intCast(n_rows)) * ch + 2 * well_pad else 0; const box_h = text_h + image_h + if (has_text) well_h + pad / 2 else 0; const x = r.x + (r.w - box_w) / 2; c.box = .{ .x = x, .y = y, .w = box_w, .h = box_h }; try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 });
var ly = y + pad / 2; try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), head, .{ fg.r, fg.g, fg.b, 255 }); ly += row_h; if (why.len > 0) { try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), why, .{ dim.r, dim.g, dim.b, 255 }); ly += row_h; } if (types.len > 0) { try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), types, .{ dim.r, dim.g, dim.b, 255 }); ly += row_h; } if (shown.h > 0) { const v = pv.?; const px = x + pad; const py = ly + pad / 4; // Over a checkerboard, so that what is transparent shows as such. const sq = @max(4, ch / 2); const light = decor.mix(bg, fg, 0.18); var cy: u32 = 0; while (cy < shown.h) : (cy += sq) { var cx: u32 = 0; while (cx < shown.w) : (cx += sq) { const tone = if ((cx / sq + cy / sq) % 2 == 0) light else fill; try w.builder.rect(@floatFromInt(px + cx), @floatFromInt(py + cy), @floatFromInt(@min(sq, shown.w - cx)), @floatFromInt(@min(sq, shown.h - cy)), .{ tone.r, tone.g, tone.b, 255 }); } } try w.builder.picture(previewKey(p), @floatFromInt(px), @floatFromInt(py), @floatFromInt(shown.w), @floatFromInt(shown.h), v.rgba_width, v.rgba_height); ly += image_h; } c.well = .{}; if (has_text) { try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ly + (row_h - ch) / 2), size_label, .{ dim.r, dim.g, dim.b, 255 }); ly += row_h; // The text in a well of the pane's own colors, so that it is // plainly not harrier's: a box of its background, edged, and the // text in its foreground, with harrier's marks dim. const wx = x + pad / 2; const ww = box_w - pad; c.well = .{ .x = wx, .y = ly, .w = ww, .h = well_h }; try w.builder.rect(@floatFromInt(wx), @floatFromInt(ly), @floatFromInt(ww), @floatFromInt(well_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(wx + one), @floatFromInt(ly + one), @floatFromInt(ww - 2 * one), @floatFromInt(well_h - 2 * one), .{ bg.r, bg.g, bg.b, 255 }); const mark = decor.mix(bg, fg, 0.45); var bufs: Confirmation.RowBuffers = .{}; var ry = ly + well_pad; for (c.scroll..c.scroll + n_rows) |i| { const row = c.layout.get(i, &bufs); // A run at a time, of the text or of marks. var at: usize = 0; while (at < row.cps.len) { var end = at + 1; while (end < row.cps.len and row.marks[end] == row.marks[at]) end += 1; const color = if (row.marks[at]) mark else fg; try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad + row.cols[at] * cw), @floatFromInt(ry), row.cps[at..end], .{ color.r, color.g, color.b, 255 }); at = end; } ry += ch; } // Where the rows shown are among them all, when they are not all. const sb: Scrollbar = .{ .rect = .{ .x = wx, .y = ly + one, .w = ww - one, .h = well_h - 2 * one }, .scale = w.scale, .bar = .{ .total = c.layout.len(), .offset = c.scroll, .len = n_rows }, }; if (sb.scrollable()) { const th = sb.thumb(false); const tc = decor.mix(bg, fg, 0.4); try w.builder.rect(@floatFromInt(th.x), @floatFromInt(th.y), @floatFromInt(th.w), @floatFromInt(th.h), .{ tc.r, tc.g, tc.b, 255 }); } ly += well_h + pad / 2; }
// The answers, right to left from the box's right edge. const by = y + box_h - pad / 2 - button_h; var bx = x + box_w - pad; const as = c.answers(); var i = as.len; while (i > 0) { i -= 1; var label_cps: [64]u21 = undefined; const label = decor.fitTitle(answerLabel(c.question, as[i]), 24, &label_cps); const bw = (@as(u32, @intCast(decor.cells(label))) + 2) * cw; if (bx < x + pad + bw) break; bx -= bw; c.buttons[i] = .{ .x = bx, .y = by, .w = bw, .h = button_h }; const selected = i == c.selected; const hovered = if (c.hover) |h| h == i else false; const f = if (selected) lit else if (hovered) hover else fill; try w.builder.rect(@floatFromInt(bx), @floatFromInt(by), @floatFromInt(bw), @floatFromInt(button_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(bx + one), @floatFromInt(by + one), @floatFromInt(bw - 2 * one), @floatFromInt(button_h - 2 * one), .{ f.r, f.g, f.b, 255 }); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(bx + cw), @floatFromInt(by + (button_h - ch) / 2), label, .{ fg.r, fg.g, fg.b, 255 }); bx -|= cw; }}
// ---------------------------------------------------------------------------// Searching// ---------------------------------------------------------------------------
/// `p`'s search, if its field is open.fn searchOpen(w: *const Window, p: *Pane) ?*Search { _ = w; const s = &(p.search orelse return null); return if (s.open) s else null;}
/// Opens the search field over the focused pane, with what was last/// searched for in it, or leaves the one open.pub fn startSearch(w: *Window) void { const p = w.pane(); if (p.search) |*s| { s.open = true; s.show_selected = false; } else p.search = .init(w.gpa); w.dirty = true;}
/// Closes `p`'s search field, and its matches stop being shown. What was/// searched for is kept, for the next and previous match to go on from/// with the field closed; with nothing typed, the search goes.fn endSearch(w: *Window, p: *Pane) void { const s = &(p.search orelse return); if (s.query.items.len > 0) { s.open = false; s.show_selected = false; } else { p.session.lock(); s.deinit(&p.session.terminal); p.session.unlock(); p.search = null; } p.rows.stale = true; w.dirty = true;}
/// The next match `dir` in the focused pane, the field open or not: from/// what was last searched for there, or, with nothing searched for yet,/// the field opens to ask.pub fn searchNext(w: *Window, dir: Search.Direction) void { const p = w.pane(); const s = &(p.search orelse return w.startSearch()); if (s.query.items.len == 0) return w.startSearch(); w.searchSelect(p, dir); if (!s.open) s.show_selected = true;}
/// A key typed while `p`'s search field is open: text goes into it, Enter/// and the arrows move between matches, Escape closes it.fn searchKey(w: *Window, p: *Pane, k: ev.Key) void { if (k.action == .release) return; const s = &p.search.?; switch (k.keysym) { 0xff1b => return w.endSearch(p), // Return and keypad Enter: older, or newer with shift. 0xff0d, 0xff8d => w.searchSelect(p, if (k.mods.shift) .newer else .older), 0xff52 => w.searchSelect(p, .older), 0xff54 => w.searchSelect(p, .newer), 0xff08 => s.backspace(), else => { if (k.mods.ctrl or k.mods.alt or k.mods.super) { // ctrl+u clears it, as a shell's line does. if (k.mods.ctrl and k.unshifted == 'u') { s.query.clearRetainingCapacity(); s.changed = true; } } else if (k.text.len > 0 and k.text[0] >= 0x20 and k.text[0] != 0x7f) { s.append(k.text) catch {}; } }, } w.dirty = true;}
fn searchSelect(w: *Window, p: *Pane, dir: Search.Direction) void { const s = &(p.search orelse return); p.session.lock(); defer p.session.unlock(); _ = s.select(&p.session.terminal, dir) catch |err| log.warn("searching: {t}", .{err}); w.dirty = true;}
/// Marks `p`'s search matches in its render state, for the frame to color:/// the selected one first, so that it wins where they overlap.fn highlightMatches(w: *Window, p: *Pane) void { const row_data = p.state.row_data.slice(); for (row_data.items(.highlights), row_data.items(.dirty)) |*hl, *dirty| if (hl.items.len > 0) { hl.clearRetainingCapacity(); dirty.* = true; }; const s = &(p.search orelse return); // Closed, only a match moved to is shown, and only until the program // is typed to. if (!s.open and !s.show_selected) return; if (s.selected()) |sel| p.state.updateHighlightsFlattened(w.gpa, frame.highlight_selected, &.{sel}) catch {}; if (s.open) p.state.updateHighlightsFlattened(w.gpa, frame.highlight_match, s.inView()) catch {};}
/// Moves each shown pane's search along: a feed, with the terminal locked/// for as long as that takes, then as much as it can do alone.fn stepSearches(w: *Window) void { // A window whose last tab has just gone has nothing shown. if (w.tabs.items.len == 0) return; var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| if (p.search) |*s| if (!p.rect.empty() and s.busy()) { if (!p.session.tryLock()) continue; s.feed(&p.session.terminal) catch |err| log.warn("searching: {t}", .{err}); p.session.unlock(); _ = s.step(); // The count, and the matches in view, may have moved on. w.dirty = true; };}
/// Whether a shown pane is still searching: the loop goes round again at/// once until it is done.fn searching(w: *const Window) bool { // A window whose last tab has just gone has nothing shown. if (w.tabs.items.len == 0) return false; var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| if (p.search) |*s| if (!p.rect.empty() and s.busy()) return true; return false;}
/// `p`'s search field, at the top right of it: what has been typed, or a/// placeholder, with a cursor, and how many matches there are and which is/// selected.fn buildSearchBar(w: *Window, p: *Pane) !void { const s = w.searchOpen(p) orelse return; const m = w.app.grid.metrics; const cw = m.cell_width; const ch = m.cell_height; const fg = p.state.colors.foreground; const bg = p.state.colors.background; const dim = decor.mix(bg, fg, 0.55); const edge = decor.mix(bg, fg, 0.35); const fill = decor.mix(bg, fg, 0.10); const one: u32 = @max(1, @as(u32, @intFromFloat(@round(w.scale)))); const pad = cw;
var count_buf: [128]u8 = undefined; var count_w: std.Io.Writer = .fixed(&count_buf); if (s.query.items.len > 0) { const total = s.total(); if (s.position()) |pos| { l10n.write(&count_w, "search-position", &.{ l10n.num("position", pos), l10n.num("total", total) }) catch {}; } else l10n.write(&count_w, "search-matches", &.{l10n.num("total", total)}) catch {}; if (s.busy()) count_w.writeAll("…") catch {}; } const count = count_w.buffered(); const count_cells: u32 = @intCast(decor.widthOf(count));
// As wide as thirty cells of query and its count, within the pane. const field_cells: u32 = 24; const want = field_cells + 2 + count_cells; const max_cells = (p.rect.w -| 2 * pad -| 4 * one) / @max(cw, 1); if (max_cells < 12) return; const cells = @min(want, max_cells); const box_w = cells * cw + 2 * pad; const box_h = ch + ch / 2; const progress: u32 = if (p.progress != null) @intFromFloat(@max(2, @round(progress_height * w.scale))) else 0; var x = p.rect.x + p.rect.w - box_w - pad; const y = p.rect.y + progress + one; // Left of the status badge, where the two would share a corner. const b = w.badge; if (!b.empty() and y < b.y + b.h and x + box_w > b.x and p.rect.x < b.x) x = @max(p.rect.x + pad, b.x -| box_w -| pad); try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 }); const ty = y + (box_h - ch) / 2;
// The count at the right; the query, its end in view, in what is left. const count_x = x + pad + (cells -| count_cells) * cw; var buf: [256]u21 = undefined; const count_cps = decor.fitTitle(count, cells, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(count_x), @floatFromInt(ty), count_cps, .{ dim.r, dim.g, dim.b, 255 }); const room = cells -| count_cells -| 2; if (s.query.items.len == 0) { const ph = decor.fitTitle(l10n.text("search-placeholder"), room, &buf); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad + cw), @floatFromInt(ty), ph, .{ dim.r, dim.g, dim.b, 255 }); try w.builder.rect(@floatFromInt(x + pad), @floatFromInt(ty), @floatFromInt(@max(2 * one, cw / 6)), @floatFromInt(ch), .{ fg.r, fg.g, fg.b, 255 }); return; } var all: [256]u21 = undefined; const shown = decor.fitEnd(s.query.items, room -| 1, &all); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad), @floatFromInt(ty), shown, .{ fg.r, fg.g, fg.b, 255 }); const cursor_x = x + pad + @as(u32, @intCast(decor.cells(shown))) * cw; try w.builder.rect(@floatFromInt(cursor_x), @floatFromInt(ty), @floatFromInt(@max(2 * one, cw / 6)), @floatFromInt(ch), .{ fg.r, fg.g, fg.b, 255 });}
/// Tells `p`'s program it gained or lost the focus, if it asked to be told.fn reportFocus(w: *Window, p: *Pane, focused: bool) void { // Looked at now: its notification and its bell have done their job. if (focused) { w.app.withdraw(p); w.unring(p); w.unask(p); } var buf: [8]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); const s = p.session; { s.lock(); defer s.unlock(); if (!s.terminal.modes.get(.focus_event)) return; vt.input.encodeFocus(&out, if (focused) .gained else .lost) catch return; } s.write(out.buffered());}
// ---------------------------------------------------------------------------// The pointer// ---------------------------------------------------------------------------
const Cell = struct { x: u16, y: u16 };
/// A link on screen, drawn underlined while ctrl is held over it: the cells/// it takes, a run of them on each row it is on.pub const LinkHover = struct { pane: *Pane, rows: [max_rows]Run = undefined, n: u8 = 0,
const max_rows = 8; const Run = struct { y: u16, x0: u16, x1: u16 };
fn add(h: *LinkHover, y: u16, x0: u16, x1: u16) void { if (h.n == max_rows) return; h.rows[h.n] = .{ .y = y, .x0 = x0, .x1 = x1 }; h.n += 1; }
fn eql(a: ?LinkHover, b: ?LinkHover) bool { const x = a orelse return b == null; const y = b orelse return false; return x.pane == y.pane and x.n == y.n and std.mem.eql(u8, std.mem.sliceAsBytes(x.rows[0..x.n]), std.mem.sliceAsBytes(y.rows[0..y.n])); }};
/// A link found under the pointer: what it opens, owned by the window's/// allocator, and where it is.const Link = struct { uri: []u8, hover: LinkHover,};
/// The link at `x`, `y` in pane `p`, if there is one: a hyperlink the/// program made (OSC 8), or a URL in the text of the line there.fn linkAt(w: *Window, p: *Pane, x: f32, y: f32) ?Link { if (!p.rect.contains(x, y)) return null; const s = p.session; s.lock(); defer s.unlock(); const screen = s.terminal.screens.active; const c = w.cellAt(p, x, y); const pin = screen.pages.pin(.{ .viewport = .{ .x = c.x, .y = c.y } }) orelse return null; var hover: LinkHover = .{ .pane = p };
const rac = pin.rowAndCell(); if (rac.cell.hyperlink) { const page = pin.node.page(); const id = page.lookupHyperlink(rac.cell) orelse return null; const uri = w.gpa.dupe(u8, page.hyperlink_set.get(page.memory, id).uri.slice(page.memory)) catch return null; // The cells beside it with the same link, on its row. const cells = pin.cells(.all); const same = struct { fn f(pg: anytype, cell: anytype, want: anytype) bool { return cell.hyperlink and pg.lookupHyperlink(cell) == want; } }.f; var x0: usize = c.x; while (x0 > 0 and same(page, &cells[x0 - 1], id)) x0 -= 1; var x1: usize = c.x; while (x1 + 1 < cells.len and same(page, &cells[x1 + 1], id)) x1 += 1; hover.add(c.y, @intCast(x0), @intCast(x1)); return .{ .uri = uri, .hover = hover }; }
if (!w.app.configFor(p).link_detection) return null; const line = screen.selectLine(.{ .pin = pin, .whitespace = null }) orelse return null; var text = screen.selectionStringMap(w.gpa, .{ .sel = line, .trim = false }) catch return null; defer text.deinit(w.gpa); const at = for (0..text.map.count()) |i| { const q = text.map.get(i) orelse continue; if (q.node == pin.node and q.x == pin.x and q.y == pin.y) break i; } else return null; const span = links.find(text.string, at) orelse return null; const uri = w.gpa.dupe(u8, span.of(text.string)) catch return null;
// Where it starts and ends on screen; a part scrolled out of view is // not underlined. const cols = s.terminal.cols; const first = if (text.map.get(span.start)) |q| screen.pages.pointFromPin(.viewport, q) else null; const last = if (text.map.get(span.end - 1)) |q| screen.pages.pointFromPin(.viewport, q) else null; const sy: u32 = if (first) |f| f.coord().y else 0; const ey: u32 = if (last) |l| l.coord().y else s.terminal.rows - 1; var row = sy; while (row <= ey) : (row += 1) { const x0: u16 = if (row == sy) if (first) |f| f.coord().x else 0 else 0; const x1: u16 = if (row == ey) if (last) |l| l.coord().x else cols - 1 else cols - 1; hover.add(@intCast(row), x0, x1); } return .{ .uri = uri, .hover = hover };}
/// Underlines the link under the pointer, and shows the hand over it, while/// ctrl is held; and stops when it is let go, or the pointer leaves it.fn updateLinkHover(w: *Window) void { w.link_ctrl = w.app.platform.mods.ctrl; var found: ?LinkHover = null; if (w.link_ctrl and w.pointer_in_content and !w.left_down) { if (w.tab().paneAt(w.pointer_x, w.pointer_y)) |p| if (w.linkAt(p, w.pointer_x, w.pointer_y)) |l| { w.gpa.free(l.uri); found = l.hover; }; } if (LinkHover.eql(found, w.link_hover)) return; const had = w.link_hover != null; w.link_hover = found; w.dirty = true; if (found != null) w.setPointerShape(.pointer) else if (had and w.pointer_in_content) w.setPointerShape(.text);}
/// Where `p`'s grid's top left corner is, in physical pixels.fn gridOrigin(w: *const Window, p: *const Pane) struct { x: u32, y: u32 } { return .{ .x = p.rect.x + w.padding, .y = p.rect.y + w.padding };}
/// A point in physical pixels as kitty's drag and drop protocol has it:/// the cell, and the pixel from the grid's top left.pub fn dndPosition(w: *const Window, p: *const Pane, x: f32, y: f32) Session.dnd.Position { const c = w.cellAt(p, x, y); const o = w.gridOrigin(p); return .{ .cell_x = c.x, .cell_y = c.y, .pixel_x = @intFromFloat(std.math.clamp(@round(x - @as(f32, @floatFromInt(o.x))), -1e9, 1e9)), .pixel_y = @intFromFloat(std.math.clamp(@round(y - @as(f32, @floatFromInt(o.y))), -1e9, 1e9)), };}
fn cellAt(w: *const Window, p: *const Pane, x: f32, y: f32) Cell { const m = w.app.grid.metrics; const o = w.gridOrigin(p); const cx = @max(0, (x - @as(f32, @floatFromInt(o.x))) / @as(f32, @floatFromInt(m.cell_width))); const cy = @max(0, (y - @as(f32, @floatFromInt(o.y))) / @as(f32, @floatFromInt(m.cell_height))); const t = &p.session.terminal; return .{ .x = @intFromFloat(@min(cx, @as(f32, @floatFromInt(t.cols - 1)))), .y = @intFromFloat(@min(cy, @as(f32, @floatFromInt(t.rows - 1)))), };}
/// Whether the program has asked for the mouse, and the user is not/// holding shift to take it back.fn mouseReporting(w: *const Window, p: *const Pane) bool { return p.session.terminal.flags.mouse_event != .none and !w.app.platform.mods.shift;}
/// `p` as the mouse encoder sees it: a screen of its own, with the pointer/// measured from its top left.fn mouseSize(w: *const Window, p: *const Pane) @FieldType(vt.input.MouseEncodeOptions, "size") { const m = w.app.grid.metrics; return .{ .screen = .{ .width = p.rect.w, .height = p.rect.h }, .cell = .{ .width = m.cell_width, .height = m.cell_height }, .padding = .{ .top = w.padding, .bottom = w.padding, .left = w.padding, .right = w.padding }, };}
fn sendMouse(w: *Window, p: *Pane, action: vt.input.MouseAction, which: ?vt.input.MouseButton) void { var buf: [64]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); vt.input.encodeMouse(&out, .{ .action = action, .button = which, .mods = keys.mods(w.app.platform.mods), .pos = .{ .x = w.pointer_x - @as(f32, @floatFromInt(p.rect.x)), .y = w.pointer_y - @as(f32, @floatFromInt(p.rect.y)) }, }, .{ .event = p.session.terminal.flags.mouse_event, .format = p.session.terminal.flags.mouse_format, .size = w.mouseSize(p), .any_button_pressed = w.left_down, }) catch return; if (out.end > 0) p.session.write(out.buffered());}
/// The pane the pointer is acting on: the one a held press began in, or/// the one under it.fn pointerPane(w: *const Window) ?*Pane { if (w.left_down) if (w.press_pane) |p| return p; return w.tab().paneAt(w.pointer_x, w.pointer_y);}
fn motion(w: *Window, x: f32, y: f32) void { w.pointer_x = x; w.pointer_y = y; // A tab held by the pointer goes where the pointer goes, along the bar, // or out of the window once it is pulled well off it. if (w.tab_bar.drag) |d| { if (w.tab_bar.tornOff(y)) { w.tearOff(d.index); return; } if (w.tab_bar.dragMotion(x)) |to| w.moveTabTo(d.index, to); if (w.tab_bar.drag.?.moving) w.dirty = true; return; } // A divider being dragged goes where the pointer goes. if (w.divider_drag) |sp| { Tab.dragDivider(sp, x, y, w.gap()); w.layoutTab(w.tab()); return; } // So does a scrollbar's thumb. if (w.scroll_drag) |d| { w.scrollTo(d.pane, y - d.grab); return; } // Over the status badge, nothing beneath it sees the pointer. const on_badge = !w.left_down and w.badge.contains(x, y); if (on_badge != w.badge_hover) { w.badge_hover = on_badge; w.dirty = true; } if (on_badge) { w.setPointerShape(.pointer); return; } // Over the profile selector, likewise. if (!w.left_down and w.profileSelectorMotion(x, y)) return; if (!w.left_down and w.confirmationMotion(x, y)) return; // A pane taken by its handle: where it would go. if (w.pane_drag) |*d| { const over = w.tab().paneAt(x, y); const target = if (over == d.pane) null else over; const side = if (target) |tp| Tab.dropSide(tp.rect, x, y) else d.side; if (target != d.target or side != d.side) { d.target = target; d.side = side; w.dirty = true; } return; } // The frame first, unless a selection is being dragged out of the grid. const hit = if (w.left_down and w.title_bar.bar.pressed == null) TitleBar.Hit.content else w.title_bar.bar.hitTest(x, y); if (!hit.eql(w.title_bar.bar.hovered)) { w.title_bar.bar.hovered = hit; w.dirty = true; } const bar_hit = if (hit.eql(.content) and !w.left_down) w.tab_bar.hitTest(x, y) else null; if (!std.meta.eql(bar_hit, w.tab_bar.hovered)) { w.tab_bar.hovered = bar_hit; w.dirty = true; } const divider = if (hit.eql(.content) and bar_hit == null and !w.left_down) w.tab().dividerAt(x, y, w.dividerGrab()) else null; const scrollbar = if (hit.eql(.content) and bar_hit == null and divider == null and !w.left_down) w.scrollbarAt(x, y) else null; w.hoverScrollbar(scrollbar); const near_handle = if (hit.eql(.content) and bar_hit == null and divider == null and scrollbar == null and !w.left_down) w.handleAt(x, y) else null; w.hoverHandle(near_handle); const on_handle = if (near_handle) |h| h.on else false; w.setPointerShape(switch (hit) { .content => if (bar_hit) |b| switch (b) { .close, .new, .status, .profiles => .pointer, .tab, .empty => .default, } else if (divider) |d| switch (d.axis) { .horizontal => .col_resize, .vertical => .row_resize, } else if (scrollbar != null) .default else if (on_handle) .grab else if (w.link_hover != null) .pointer else .text, .title => .default, .close, .maximize, .minimize, .leading, .trailing => .pointer, .edge => |e| e.cursor(), }); w.pointer_in_content = hit.eql(.content) and bar_hit == null and divider == null and scrollbar == null and !on_handle; w.updateLinkHover(); if (!w.pointer_in_content) return; // A press dragged more than a cell's width, over a program offering // drags, asks it to offer one — whether or not it has the mouse, as // kitty does. if (w.left_down) if (w.dnd_press) |press| if (w.press_pane) |pp| { const dx = x - press.x; const dy = y - press.y; const reach: f32 = @floatFromInt(w.app.grid.metrics.cell_width); if (dx * dx + dy * dy > reach * reach) { w.dnd_press = null; _ = w.app.dnd.gesture(w, pp, x, y); } }; const p = w.pointerPane() orelse return; const s = p.session; s.lock(); defer s.unlock(); if (w.mouseReporting(p)) { w.autoscroll_at = null; w.sendMouse(p, .motion, if (w.left_down) .left else null); return; } if (!w.left_down) return; const t = &s.terminal; const c = w.cellAt(p, x, y); const pin = t.screens.active.pages.pin(.{ .viewport = .{ .x = c.x, .y = c.y } }) orelse return; const d = w.selectionDrag(p, x, y); if (p.gesture.drag(t, .{ .pin = pin, .xpos = d.xpos, .ypos = d.ypos, .rectangle = d.rectangle, .word_boundary_codepoints = d.word_boundary_codepoints, .geometry = d.geometry, })) |sel| { t.screens.active.select(sel) catch {}; w.dirty = true; } w.followAutoscroll(p);}
/// A selection dragged to `x`, `y`, measured from pane `p` as if it were/// the whole screen: above its top or below its bottom, the gesture asks/// for the pane to be scrolled.fn selectionDrag(w: *const Window, p: *const Pane, x: f32, y: f32) vt.SelectionGesture.AutoscrollTick { const t = &p.session.terminal; const m = w.app.grid.metrics; const c = w.cellAt(p, x, y); return .{ .viewport = .{ .x = c.x, .y = c.y }, .xpos = x - @as(f32, @floatFromInt(p.rect.x)), .ypos = y - @as(f32, @floatFromInt(p.rect.y)), .rectangle = w.app.platform.mods.alt, .word_boundary_codepoints = &word_boundaries, .geometry = .{ .columns = t.cols, .cell_width = m.cell_width, .padding_left = w.padding, .screen_height = p.rect.h }, };}
/// Starts or stops the scrolling of a selection dragged past the top or/// bottom of pane `p`, the one its press began in: it runs while the/// gesture asks for it, the pane is shown, and there is further to go,/// and pauses at the top of the scrollback or at the live screen rather/// than waking for nothing. With the session locked.fn followAutoscroll(w: *Window, p: *Pane) void { const pages = &p.session.terminal.screens.active.pages; const top: f32 = @floatFromInt(p.rect.y); const bottom: f32 = @floatFromInt(p.rect.y + p.rect.h); const past: f32, const further = switch (p.gesture.left_drag_autoscroll) { .none => .{ 0, false }, .up => .{ top - w.pointer_y, !pages.getTopLeft(.viewport).eql(pages.getTopLeft(.screen)) }, .down => .{ w.pointer_y - bottom, pages.viewport != .active }, }; if (!further or !w.left_down or w.press_pane != p or !w.shows(p)) { w.autoscroll_at = null; return; } // Moved further out, it comes sooner; moved back in, the row already // due still comes when it was due. const rows = past / @as(f32, @floatFromInt(w.app.grid.metrics.cell_height)); const next = std.Io.Clock.awake.now(w.app.io).addDuration(autoscrollInterval(rows)); if (w.autoscroll_at == null or next.compare(.lt, w.autoscroll_at.?)) w.autoscroll_at = next;}
/// The next row of a selection being dragged past its pane's top or/// bottom: the pane scrolls one, and the selection takes in the row now/// under the pointer.fn autoscroll(w: *Window) void { w.autoscroll_at = null; const p = w.press_pane orelse return; const s = p.session; s.lock(); defer s.unlock(); if (!w.left_down or !w.shows(p) or w.mouseReporting(p)) return; const t = &s.terminal; if (p.gesture.autoscrollTick(t, w.selectionDrag(p, w.pointer_x, w.pointer_y))) |sel| t.screens.active.select(sel) catch {}; w.dirty = true; w.followAutoscroll(p);}
/// Whether pane `p` is in the tab shown.fn shows(w: *const Window, p: *const Pane) bool { var buf: [Tab.max_panes]*Pane = undefined; return std.mem.findScalar(*Pane, w.tab().panes(&buf), @constCast(p)) != null;}
/// The handle a pane is picked up by: a strip at the middle of its top/// edge, in physical pixels.fn handleRect(w: *const Window, p: *const Pane) Pane.Rect { const width = @min(@as(u32, @intFromFloat(@round(handle_width * w.scale))), p.rect.w / 2); return .{ .x = p.rect.x + (p.rect.w - width) / 2, .y = p.rect.y, .w = width, .h = @intFromFloat(@round(handle_height * w.scale)), };}
/// The size of a pane's handle, in logical pixels.const handle_width = 80;const handle_height = 12;
/// The pane whose handle is shown with the pointer at `x`, `y` — near the/// top of a pane, in a tab of more than one — and whether the pointer is/// on the handle itself.fn handleAt(w: *const Window, x: f32, y: f32) ?HandleHit { const t = w.tab(); if (t.zoomed or t.count() < 2) return null; const p = t.paneAt(x, y) orelse return null; const h = w.handleRect(p); // Shown within the top fifth of the pane, and at least the handle. const band = @max(h.h, p.rect.h / 5); if (y >= @as(f32, @floatFromInt(p.rect.y + band))) return null; const on = x >= @as(f32, @floatFromInt(h.x)) and x < @as(f32, @floatFromInt(h.x + h.w)) and y < @as(f32, @floatFromInt(h.y + h.h)); return .{ .pane = p, .on = on };}
const HandleHit = struct { pane: *Pane, on: bool };
fn hoverHandle(w: *Window, h: ?HandleHit) void { const pane_: ?*Pane = if (h) |v| v.pane else null; const on = if (h) |v| v.on else false; if (pane_ == w.handle_pane and on == w.handle_hover) return; w.handle_pane = pane_; w.handle_hover = on; w.dirty = true;}
/// Lets go of a pane taken by its handle: beside the pane under the/// pointer, if there is one, with the focus.fn dropPane(w: *Window) void { const d = w.pane_drag orelse return; w.pane_drag = null; w.dirty = true; const target = d.target orelse return; const t = w.tab(); t.move(d.pane, target, d.side); // The splits are new: a divider held is no longer one. w.divider_drag = null; w.layoutTab(t); w.focusPane(d.pane);}
fn scrollbarMode(w: *const Window) Scrollbar.Mode { return switch (w.app.config().window.scrollbar) { inline else => |m| @field(Scrollbar.Mode, @tagName(m)), };}
fn scrollbarOf(w: *const Window, p: *const Pane) Scrollbar { return .{ .rect = p.rect, .scale = w.scale, .bar = p.scrollbar };}
/// The pane whose scrollbar is at `x`, `y`: one with scrollback to scroll,/// the pointer along its right edge.fn scrollbarAt(w: *const Window, x: f32, y: f32) ?*Pane { if (w.scrollbarMode() == .never) return null; const p = w.tab().paneAt(x, y) orelse return null; const sb = w.scrollbarOf(p); if (!sb.scrollable() or !sb.strip().contains(x, y)) return null; return p;}
/// The pointer is at `over`'s scrollbar, or none's.fn hoverScrollbar(w: *Window, over: ?*Pane) void { var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| { const now = p == over; if (p.scrollbar_hover != now) { p.scrollbar_hover = now; w.dirty = true; } }}
/// Scrolls `p` to show what its thumb at `top` stands for.fn scrollTo(w: *Window, p: *Pane, top: f32) void { const row = w.scrollbarOf(p).rowFor(top); p.session.lock(); defer p.session.unlock(); p.session.terminal.scrollViewport(.{ .row = row }); w.dirty = true;}
/// How far either side of a divider a press still takes it.fn dividerGrab(w: *const Window) u32 { return @intFromFloat(@round(3 * w.scale));}
const word_boundaries = [_]u21{ ' ', '\t', '"', '\'', '`', '|', ':', ';', ',', '(', ')', '[', ']', '{', '}', '<', '>', '$' };
fn setPointerShape(w: *Window, shape: ev.Cursor) void { if (shape == w.pointer_shape) return; w.pointer_shape = shape; w.pw.setCursor(shape);}
/// A press or release on the title bar or the frame; false when the/// pointer is over the terminal and the press is the terminal's.fn decorButton(w: *Window, b: ev.MouseButton, pressed: bool) bool { const bar = &w.title_bar.bar; const outcome = if (pressed) bar.press(w.pw, b, w.pointer_x, w.pointer_y, std.Io.Clock.awake.now(w.app.io)) else bar.release(w.pw, b, w.pointer_x, w.pointer_y); switch (outcome) { .content => return false, .handled => {}, .close => w.closing = true, .leading => w.toggleProfileSelector(), .trailing => w.app.toggleStatusWindow(w), } return true;}
/// Hands tab `i` to the window system's drag and drop, with a picture of/// it under the pointer. Where it is dropped decides where it goes; see/// `App.dropTab`. Without drag and drop, the tab stays on the bar.fn tearOff(w: *Window, i: usize) void { const d = w.tab_bar.drag orelse return; var pixels: std.ArrayList(u32) = .empty; defer pixels.deinit(w.gpa); const icon = w.dragIcon(i, d.grab, &pixels) catch |err| blk: { log.info("no picture for the dragged tab: {t}", .{err}); break :blk null; }; w.pw.startLocalDrag(icon) catch |err| { log.info("cannot drag a tab out of the window: {t}", .{err}); return; }; w.app.tab_drag = .{ .window = w, .tab = w.tabs.items[i] }; // The compositor has the pointer now, and the release goes to it. w.tab_bar.endDrag(); w.tab_bar.hovered = null; w.left_down = false; w.dirty = true;}
/// A picture of tab `i` alone, held at `grab` from its left edge.fn dragIcon(w: *Window, i: usize, grab: f32, pixels: *std.ArrayList(u32)) !ev.DragIcon { const c = w.themeColors(); // Built after whatever the last frame left, and taken off again. const keep = w.builder.instances.items.len; defer w.builder.instances.shrinkRetainingCapacity(keep); const size = try w.tab_bar.buildDragImage(&w.builder, &w.app.grid, &w.app.glyphs, w.tabs.items[i].title(), c); try pixels.resize(w.gpa, size.width * size.height); @memset(pixels.items, 0); cpu.draw(w.builder.instances.items[keep..], &w.app.glyphs.alpha, &w.app.glyphs.color, pixels.items, size.width, size.height); const y_in_bar = w.pointer_y - @as(f32, @floatFromInt(w.tab_bar.y)); return .{ .width = size.width, .height = size.height, .pixels = pixels.items, .scale = w.scale, .hot_x = @intFromFloat(std.math.clamp(grab, 0, @as(f32, @floatFromInt(size.width - 1)))), .hot_y = @intFromFloat(std.math.clamp(y_in_bar, 0, @as(f32, @floatFromInt(size.height - 1)))), };}
/// The colors the terminal is drawn in now, for what is drawn around it.fn themeColors(w: *const Window) decor.Colors { const st = &w.pane().state; if (st.rows > 0) return .{ .foreground = st.colors.foreground, .background = st.colors.background }; return .{ .foreground = w.app.theme.foreground, .background = w.app.theme.background };}
/// A press or release on the tab bar; false when it is not the bar's.fn tabBarButton(w: *Window, b: ev.MouseButton, pressed: bool) bool { const hit = w.tab_bar.hitTest(w.pointer_x, w.pointer_y); if (!pressed and b == .left and w.tab_bar.drag != null) { // A dragged tab settles into its place. w.tab_bar.endDrag(); w.dirty = true; return true; } if (!pressed) { // The close and new-tab buttons act on release, if it is over the // button that was pressed. const was = w.tab_bar.pressed orelse return false; w.tab_bar.pressed = null; if (b != .left) return true; const now = hit orelse return true; if (!was.eql(now)) return true; switch (now) { .close => |i| w.closeTab(i), .new => w.newTabHere(), .status => w.app.toggleStatusWindow(w), .profiles => w.toggleProfileSelector(), else => {}, } return true; } const h = hit orelse return false; switch (b) { .left => switch (h) { // A tab is shown as soon as it is pressed, and taken hold of // in case it is dragged. .tab => |i| { w.select(i); w.tab_bar.beginDrag(i, w.pointer_x); }, .close, .new, .status, .profiles => w.tab_bar.pressed = h, .empty => { const now = std.Io.Clock.awake.now(w.app.io); const double = if (w.last_bar_press) |t| t.durationTo(now).toMilliseconds() < 400 else false; w.last_bar_press = if (double) null else now; if (double) w.newTabHere(); }, }, // The middle button closes a tab, as it does in a browser. .middle => switch (h) { .tab, .close => |i| w.closeTab(i), else => {}, }, else => {}, } return true;}
fn button(w: *Window, b: ev.MouseButton, pressed: bool) void { // The status badge opens the status window on release. if (w.badge_press and !pressed) { w.badge_press = false; if (b == .left and w.badge.contains(w.pointer_x, w.pointer_y)) w.app.toggleStatusWindow(w); return; } if (pressed and !w.left_down and w.badge.contains(w.pointer_x, w.pointer_y)) { w.badge_press = true; return; } if (!w.left_down and w.title_bar.bar.pressed == null and w.tab_bar.pressed == null and w.profileSelectorButton(b, pressed)) return; if (!w.left_down and w.confirmationButton(b, pressed)) return; if (w.title_bar.bar.enabled and (pressed or w.title_bar.bar.pressed != null) and w.decorButton(b, pressed)) return; if (!w.left_down and (pressed or w.tab_bar.pressed != null or w.tab_bar.drag != null) and w.tabBarButton(b, pressed)) return; // A pane's handle: taken by a left press, and the pane dropped where // the pointer is on release. if (b == .left and !pressed and w.pane_drag != null) { w.dropPane(); return; } if (b == .left and pressed and !w.left_down) if (w.handleAt(w.pointer_x, w.pointer_y)) |h| if (h.on) { w.pane_drag = .{ .pane = h.pane }; w.hoverHandle(null); w.setPointerShape(.grabbing); w.dirty = true; return; }; // A divider: taken by a left press, and let go of on release. if (b == .left and !pressed and w.divider_drag != null) { w.divider_drag = null; return; } if (b == .left and pressed and !w.left_down) if (w.tab().dividerAt(w.pointer_x, w.pointer_y, w.dividerGrab())) |d| { w.divider_drag = d; return; }; // A scrollbar: the thumb taken where it was pressed, or the track // pressed, which brings the thumb's middle there first. if (b == .left and !pressed and w.scroll_drag != null) { w.scroll_drag = null; w.dirty = true; return; } if (b == .left and pressed and !w.left_down) if (w.scrollbarAt(w.pointer_x, w.pointer_y)) |p| { const th = w.scrollbarOf(p).thumb(true); const top: f32 = @floatFromInt(th.y); const on_thumb = w.pointer_y >= top and w.pointer_y < top + @as(f32, @floatFromInt(th.h)); const grab = if (on_thumb) w.pointer_y - top else @as(f32, @floatFromInt(th.h)) / 2; w.scroll_drag = .{ .pane = p, .grab = grab }; if (!on_thumb) w.scrollTo(p, w.pointer_y - grab); return; };
// A press in a pane focuses it, and goes to it as well. const p = (if (pressed) w.tab().paneAt(w.pointer_x, w.pointer_y) else w.pointerPane()) orelse w.pane(); if (pressed) w.focusPane(p); // With the pane's search field open, the middle button pastes into it, // whether or not the program has the mouse. if (b == .middle and w.searchOpen(p) != null) { if (pressed) w.paste(.primary); return; } // Ctrl+click on a link opens it, whether or not the program has the // mouse, and the release that follows goes nowhere either. if (b == .left and !pressed and w.link_press) { w.link_press = false; return; } if (b == .left and pressed and w.app.platform.mods.ctrl) if (w.linkAt(p, w.pointer_x, w.pointer_y)) |l| { w.link_press = true; w.app.openLink(w, l.uri); return; }; if (b == .left) { w.left_down = pressed; w.press_pane = if (pressed) p else null; w.autoscroll_at = null; w.dnd_press = if (pressed and p.dnd_offers) .{ .x = w.pointer_x, .y = w.pointer_y } else null; } var copy_primary = false; { const s = p.session; s.lock(); defer s.unlock();
if (w.mouseReporting(p)) { w.sendMouse(p, if (pressed) .press else .release, switch (b) { .left => .left, .middle => .middle, .right => .right, .back => .eight, .forward => .nine, .other => .unknown, }); return; } const t = &s.terminal; const c = w.cellAt(p, w.pointer_x, w.pointer_y); const pin = t.screens.active.pages.pin(.{ .viewport = .{ .x = c.x, .y = c.y } }); if (b == .left) { if (pressed) { const at = pin orelse return; const sel = p.gesture.press(t, .{ .time = std.Io.Clock.awake.now(w.app.io), .pin = at, .xpos = w.pointer_x - @as(f32, @floatFromInt(p.rect.x)), .ypos = w.pointer_y - @as(f32, @floatFromInt(p.rect.y)), .max_distance = @floatFromInt(w.app.grid.metrics.cell_width), .repeat_interval = 500 * std.time.ns_per_ms, .word_boundary_codepoints = &word_boundaries, }) catch return; t.screens.active.select(sel) catch {}; } else { p.gesture.release(t, .{ .pin = pin }); copy_primary = t.screens.active.selection != null; } w.dirty = true; } } if (copy_primary) w.copy(.primary); if (b == .middle and pressed) w.paste(.primary);}
/// The wheel scrolls the pane under the pointer.fn scroll(w: *Window, dy: f32) void { if (w.profileSelectorScroll(dy)) return; if (w.confirmationScroll(dy)) return; const tb = w.tab().paneAt(w.pointer_x, w.pointer_y) orelse w.pane(); tb.scroll_rest += dy; const lines: i32 = @intFromFloat(tb.scroll_rest); if (lines == 0) return; tb.scroll_rest -= @floatFromInt(lines);
var buf: [256]u8 = undefined; var out: std.Io.Writer = .fixed(&buf); const s = tb.session; { s.lock(); defer s.unlock(); const t = &s.terminal; if (w.mouseReporting(tb)) { for (0..@abs(lines)) |_| w.sendMouse(tb, .press, if (lines < 0) .four else .five); return; } if (t.screens.active_key == .alternate and t.modes.get(.mouse_alternate_scroll)) { // A full-screen program that does not take the mouse gets the // wheel as arrow keys. const seq: []const u8 = if (t.modes.get(.cursor_keys)) (if (lines < 0) "\x1bOA" else "\x1bOB") else (if (lines < 0) "\x1b[A" else "\x1b[B"); for (0..@min(@abs(lines), 16)) |_| out.writeAll(seq) catch break; } else { t.scrollViewport(.{ .delta = lines * 3 }); w.dirty = true; } } if (out.end > 0) s.write(out.buffered());}
// ---------------------------------------------------------------------------// The clipboard// ---------------------------------------------------------------------------
pub fn copy(w: *Window, which: ev.Clipboard) void { const s = w.pane().session; const text = blk: { s.lock(); defer s.unlock(); const screen = s.terminal.screens.active; const sel = screen.selection orelse return; break :blk screen.selectionString(w.gpa, .{ .sel = sel }) catch return; }; defer w.gpa.free(text); w.app.platform.setClipboardText(which, text); // Copying by selecting, to the primary selection, happens at every // release of the button, and says nothing. if (which == .standard) w.toast(w.pane(), .{ .who = .user, .what = .copied, .clipboard = .standard, .contents = &.{.{ .mime = "text/plain", .data = text }}, });}
/// Puts what a program wrote, every type of it, on clipboard `which`.fn setClipboard(w: *Window, which: ev.Clipboard, contents: []const vt.clipboard.Content) !void { var buf: [vt.kitty.clipboard.max_write_mimes]ev.ClipboardContent = undefined; const n = @min(contents.len, buf.len); for (contents[0..n], buf[0..n]) |c, *b| b.* = .{ .mime = c.mime, .data = c.data }; try w.app.platform.setClipboard(which, buf[0..n]);}
/// Pastes the clipboard `which` into the focused pane: into its search/// field, when that is open, and otherwise to its program.pub fn paste(w: *Window, which: ev.Clipboard) void { if (w.profile_selector == null and w.searchOpen(w.pane()) == null and w.pasteEvent(w.pane(), which)) return; const text = (w.app.platform.readClipboard(w.gpa, which) catch |err| { log.warn("reading the {t} clipboard: {t}", .{ which, err }); return; }) orelse return; defer w.gpa.free(text); if (w.profile_selector) |*pl| { pl.paste(w.app.config().profiles, text) catch {}; w.dirty = true; return; } if (w.searchOpen(w.pane())) |search| { search.paste(text) catch {}; w.dirty = true; return; } w.pasteInto(w.pane(), text, if (which == .primary) .primary else .clipboard);}
/// Tells `p`'s program of a paste from clipboard `which`, when it asked for/// kitty's paste events (mode 5522): with the Clipboard types there are,/// and nothing of what is there, which it fetches itself if it wants it./// Whether it was told.fn pasteEvent(w: *Window, p: *Pane, which: ev.Clipboard) bool { const types = w.app.platform.clipboardTypes(which); const s = p.session; s.lock(); defer s.unlock(); if (!s.terminal.modes.get(.kitty_paste_events)) return false; // Nothing on the clipboard is nothing to paste, as without the event. if (types.len == 0) return true; s.terminal.scrollViewport(.bottom); _ = s.stream.handler.paste(.{ .source = .{ .clipboard = if (which == .primary) .primary else .standard }, .contents = .{ .reader = .{ .mimes = types, .read = .{ .read_fn = readNothing } } }, }) catch |err| log.warn("paste event: {t}", .{err}); return true;}
/// A paste event lists what is on the clipboard and reads none of it.fn readNothing(_: ?*anyopaque, _: []const u8, _: *std.Io.Writer) vt.clipboard.MimeReader.Error!void { return error.ReadFailed;}
/// Pastes `text` into `p`'s program; first asking, when the paste is unsafe/// and the configuration says to ask. Does not take `text`.pub fn pasteInto(w: *Window, p: *Pane, text: []const u8, from: Confirmation.PasteFrom) void { if (w.app.configFor(p).clipboard.confirm_unsafe_paste) { // A program that asked for kitty's paste events (mode 5522) is told // of a paste from a clipboard rather than sent it, and fetches it // itself: nothing of it runs as it arrives. const bracketed, const event = blk: { p.session.lock(); defer p.session.unlock(); const modes = &p.session.terminal.modes; break :blk .{ modes.get(.bracketed_paste), from != .drop and modes.get(.kitty_paste_events) }; }; if (!event and Confirmation.unsafePaste(text, bracketed)) { const owned = w.gpa.dupe(u8, text) catch return; return w.ask(p, .{ .paste = .{ .text = owned, .from = from } }); } } sendPaste(p, text, from);}
/// Pastes `text` into `p`'s program as it is, asked about or not.fn sendPaste(p: *Pane, text: []const u8, from: Confirmation.PasteFrom) void { const s = p.session; s.lock(); defer s.unlock(); s.terminal.scrollViewport(.bottom); _ = s.stream.handler.paste(.{ .source = switch (from) { .clipboard => .{ .clipboard = .standard }, .primary => .{ .clipboard = .primary }, .drop => .text, }, .contents = .{ .memory = &.{.{ .mime = "text/plain", .data = text }} }, // Asked about already, if it was going to be. .allow_unsafe = true, }) catch |err| log.warn("paste: {t}", .{err});}
// ---------------------------------------------------------------------------// The session's events, and drawing// ---------------------------------------------------------------------------
/// Takes what every pane's session has said since the last turn of the/// loop, and closes the panes that are finished, and the tabs with them.pub fn update(w: *Window) void { w.stepSearches(); const now = std.Io.Clock.awake.now(w.app.io); // Size overlays whose moment is over, and the next step of a sweeping // progress bar. if (w.nextDeadline() != null) { var buf: [Tab.max_panes]*Pane = undefined; for (w.tab().panes(&buf)) |p| if (p.size_shown_until) |u| if (!now.compare(.lt, u)) { p.size_shown_until = null; w.dirty = true; }; for (w.tab().panes(&buf)) |p| if (p.flash_until) |u| if (!now.compare(.lt, u)) { p.flash_until = null; w.dirty = true; }; for (w.tab().panes(&buf)) |p| if (p.toast) |t| if (!now.compare(.lt, t.until)) { p.toast = null; w.dirty = true; }; if (w.sweeping() and !now.compare(.lt, w.sweep_at)) { w.sweep_at = now.addDuration(.fromMilliseconds(sweep_ms)); w.dirty = true; } for (w.tab().panes(&buf)) |p| if (p.images.next_frame) |f| if (!p.rect.empty() and !now.compare(.lt, f)) { // Moved on when it is drawn. p.images.next_frame = null; w.dirty = true; }; // A shell that says nothing of where it is: `/proc` is asked again, // once a second. if (w.checksLocation() and !now.compare(.lt, w.location_check_at)) { w.location_check_at = now.addDuration(.fromMilliseconds(location_check_ms)); if (w.tab().focused.refreshLocation()) w.dirty = true; } if (w.autoscroll_at) |a| if (!now.compare(.lt, a)) w.autoscroll(); } // Ctrl pressed or let go with the pointer still: a link under it is // underlined, or no longer. if (w.app.platform.mods.ctrl != w.link_ctrl) w.updateLinkHover(); for (w.tabs.items, 0..) |t, ti| { var buf: [Tab.max_panes]*Pane = undefined; for (t.panes(&buf)) |p| { const c = p.update(); // Output often means another program: one just started, or // the shell's prompt back. if (c.dirty and w.app.checkForeground(p, false) and ti == w.active) w.dirty = true; // What it showed has changed under the underline. if (c.dirty) if (w.link_hover) |h| if (h.pane == p) w.updateLinkHover(); const shown = ti == w.active and !p.rect.empty(); if (c.dirty and shown) w.dirty = true; // Any tab's progress is on the tab bar. if (c.progress and w.tab_bar.visible()) w.dirty = true; // A program that stopped reporting its progress without saying // it was done: killed, say. if (p.progress) |pr| if (pr.expires and !now.compare(.lt, pr.at.addDuration(.fromMilliseconds(Pane.progress_timeout_ms)))) { p.progress = null; if (shown or w.tab_bar.visible()) w.dirty = true; }; if (c.location and p == t.focused and ti == w.active and w.showsSubtitle()) w.dirty = true; if (c.bell) w.ring(ti, p, now); // A new title often means a new directory: `/proc` is asked // again, for shell search, which lists every pane, not only // the focused one the subtitle shows. if (c.title and p.refreshLocation() and p == t.focused and ti == w.active and w.showsSubtitle()) w.dirty = true; if (c.title and p == t.focused) { if (ti == w.active) w.showTitle(); if (w.tab_bar.visible()) w.dirty = true; } if (c.notifications.len > 0) { defer { for (c.notifications) |n| n.deinit(p.session.gpa); p.session.gpa.free(c.notifications); } for (c.notifications) |n| w.app.notify(w, ti, p, n); } if (c.notices.len > 0) { defer { for (c.notices) |n| n.deinit(p.session.gpa); p.session.gpa.free(c.notices); } for (c.notices) |n| w.app.notifyStatus(w, ti, p, n); } // Something the status popup shows, in whichever window. if (c.progress) w.app.status_changed = true; if (c.dnd) |batch| { var b = batch; defer b.deinit(); w.app.dnd.handle(w, p, b.events); } if (c.clipboard) |clip| { defer clip.deinit(p.session.gpa); const which: Confirmation.Clipboard = if (clip.location == .primary or clip.location == .selection) .primary else .standard; switch (p.clipboardAccess(w.app.config(), w.app.configFor(p), .write)) { .allow => w.answerWrite(p, which, clip.contents, &clip.reply, true, false), .deny => { log.info("a program's clipboard write refused, as the configuration says", .{}); w.answerWrite(p, which, clip.contents, &clip.reply, false, false); }, .ask => if (Session.dupeContents(w.gpa, clip.contents)) |contents| { const preview = Confirmation.imagePreview(w.gpa, contents, w.previews); w.previews += 1; w.ask(p, .{ .write = .{ .contents = contents, .clipboard = which, .reply = clip.reply, .preview = preview } }); } else |_| p.session.answerClipboardWrite(&clip.reply, .io_error), } } if (c.clipboard_reads.len > 0) { defer p.session.gpa.free(c.clipboard_reads); for (c.clipboard_reads) |read| { const loc = read.reply.location; const which: Confirmation.Clipboard = if (loc == .primary or loc == .selection) .primary else .standard; // What the user pasted, fetched after a kitty paste // event: they have said yes already. if (read.granted) { w.answerRead(p, which, &read.reply, true); continue; } switch (p.clipboardAccess(w.app.config(), w.app.configFor(p), .read)) { .allow => { // A listing reads only which types there are. if (!read.listing) { var name_buf: [64]u8 = undefined; w.toast(p, .{ .who = .{ .program = w.programName(p, &name_buf) }, .what = .read, .clipboard = which }); } w.answerRead(p, which, &read.reply, true); }, .deny => w.answerRead(p, which, &read.reply, false), // Which types there are, and nothing of what is // there, kitty tells without asking. .ask => if (read.listing) w.answerRead(p, which, &read.reply, true) else w.ask(p, .{ .read = .{ .clipboard = which, .reply = read.reply } }), } } } } } var ti: usize = w.tabs.items.len; while (ti > 0) { ti -= 1; var buf: [Tab.max_panes]*Pane = undefined; // Copied: closing a pane changes the tree. const ps = w.tabs.items[ti].panes(&buf); for (ps) |p| if (p.closing) { if (w.closePane(ti, p)) break; }; }}
pub fn wantsDraw(w: *const Window) bool { return w.dirty and w.width > 0 and w.height > 0 and w.pw.ready();}
pub fn draw(w: *Window) !void { w.dirty = false; defer startup.markOnce(.first_draw, "first frame drawn"); const t = w.tab(); var buf: [Tab.max_panes]*Pane = undefined; const ps = t.panes(&buf); // A terminal busy reflowing to a new size is drawn as it last was, rather // than waited for; it says when it is done, and is drawn again then. var updated: [Tab.max_panes]bool = @splat(false); const now = std.Io.Clock.awake.now(w.app.io); const m = w.app.grid.metrics; for (ps, 0..) |p, i| if (!p.rect.empty()) { if (!p.session.tryLock()) continue; defer p.session.unlock(); try p.state.beginUpdate(w.gpa, &p.session.terminal); p.scrollbar = p.session.terminal.screens.active.pages.scrollbar(); p.grapheme_cluster = p.session.terminal.modes.get(.grapheme_cluster); try p.images.update(w.gpa, w.app.io, p.id, &p.session.terminal, m.cell_width, m.cell_height, w.renderer.held(), now); // The search learns what changed on screen, and where the viewport // is, for the matches in view. if (p.search) |*s| s.feed(&p.session.terminal) catch |err| log.warn("searching: {t}", .{err}); updated[i] = true; }; // The images the renderer had not got are uploaded with this frame, // and then done with. var uploads: std.ArrayList(images.Upload) = .empty; defer { for (uploads.items) |*u| u.deinit(w.gpa); uploads.deinit(w.gpa); } for (ps) |p| { try uploads.appendSlice(w.gpa, p.images.uploads.items); p.images.uploads.clearRetainingCapacity(); // A confirmation's image, the first time it is shown here. const pv = previewOf(p) orelse continue; const held = w.renderer.held(); if (held.has(held.context, previewKey(p).gpu(), pv.generation)) continue; const data = try w.gpa.dupe(u8, pv.rgba); uploads.append(w.gpa, .{ .key = previewKey(p).gpu(), .generation = pv.generation, .width = pv.rgba_width, .height = pv.rgba_height, .format = .rgba, .data = data, }) catch |err| { w.gpa.free(data); return err; }; } for (ps, 0..) |p, i| if (updated[i]) { p.state.endUpdate(); w.highlightMatches(p); };
w.buildFrame(t, ps) catch |err| switch (err) { error.AtlasFull => { // The atlas holds glyphs nothing shows any more: start it // again, and the frame with it. w.app.glyphs.clear(); for (ps) |p| p.rows.stale = true; try w.buildFrame(t, ps); }, else => return err, }; for (ps) |p| if (!p.rect.empty()) p.state.clean();
// Translucent, each pane draws its own background, and what is not a // pane — nothing, once the frame and the bars are drawn — is clear. const bg = t.focused.state.colors.background; const clear: [4]f32 = if (w.opacity() < 1) .{ 0, 0, 0, 0 } else .{ @as(f32, @floatFromInt(bg.r)) / 255, @as(f32, @floatFromInt(bg.g)) / 255, @as(f32, @floatFromInt(bg.b)) / 255, 1, }; try w.renderer.draw(w.width, w.height, clear, w.builder.instances.items, w.builder.runs.items, uploads.items, &w.app.glyphs.alpha, &w.app.glyphs.color, w.target.target()); if (w.focused) w.pw.setTextInput(w.cursorRect());}
/// How much an unfocused pane is faded toward its background, when there/// is more than one to tell apart.const unfocused_fade = 90;
/// `p`'s scrollbar, if it is to be shown: the thumb, and under the pointer/// the track too, in the pane's own colors.fn buildScrollbar(w: *Window, p: *Pane) !void { const sb = w.scrollbarOf(p); const dragging = if (w.scroll_drag) |d| d.pane == p else false; const hovered = p.scrollbar_hover or dragging; if (!sb.shown(w.scrollbarMode(), hovered)) return; const fg = p.state.colors.foreground; const bg = p.state.colors.background; if (hovered) { const tr = sb.track(true); const c = decor.mix(bg, fg, 0.12); try w.builder.rect(@floatFromInt(tr.x), @floatFromInt(tr.y), @floatFromInt(tr.w), @floatFromInt(tr.h), .{ c.r, c.g, c.b, 255 }); } const th = sb.thumb(hovered); const c = decor.mix(bg, fg, if (dragging) 0.7 else if (hovered) 0.55 else 0.4); try w.builder.rect(@floatFromInt(th.x), @floatFromInt(th.y), @floatFromInt(th.w), @floatFromInt(th.h), .{ c.r, c.g, c.b, 255 });}
/// `p`'s progress, as its program last reported it: a bar along its top/// edge, in its palette's blue, red for an error and yellow when paused./// With no amount to show, a stretch of it sweeps from side to side; an/// error or a pause with none is the whole width.fn buildProgress(w: *Window, p: *Pane) !void { const span = w.progressSpan(p.progress.?, &p.state.colors); const r = p.rect; const x: f32 = @floatFromInt(r.x); const y: f32 = @floatFromInt(r.y); const width: f32 = @floatFromInt(r.w); const h: f32 = @max(2, @round(progress_height * w.scale)); if (span.track) { const track = decor.mix(p.state.colors.background, p.state.colors.foreground, 0.15); try w.builder.rect(x, y, width, h, .{ track.r, track.g, track.b, 255 }); } const c = span.color; try w.builder.rect(@round(x + span.start * width), y, @round(span.len * width), h, .{ c.r, c.g, c.b, 255 });}
/// Where a progress bar is filled, as fractions of its width, and in which/// color — the configuration's, or else `colors`' palette's: filled as far/// as it says over a track; the whole/// of it for an error or a pause that gives no amount; or a quarter of it,/// sweeping there and back every two seconds, when the program cannot/// say.fn progressSpan(w: *const Window, pr: Pane.Progress, colors: *const vt.RenderState.Colors) TabBar.Progress { const color = w.progressColor(pr.state, colors); if (pr.percent) |pct| return .{ .color = color, .start = 0, .len = @as(f32, @floatFromInt(@min(pct, 100))) / 100, .track = true, }; if (!sweeps(pr)) return .{ .color = color, .start = 0, .len = 1, .track = false }; const period_ms = 2000; const ms: u64 = @intCast(@divTrunc(std.Io.Clock.awake.now(w.app.io).nanoseconds, std.time.ns_per_ms)); const phase = @as(f32, @floatFromInt(ms % period_ms)) / period_ms; const there = if (phase < 0.5) phase * 2 else (1 - phase) * 2; return .{ .color = color, .start = there * 0.75, .len = 0.25, .track = false };}
/// The color progress in `state` is shown in: the configuration's, or else/// `colors`' palette's.fn progressColor(w: *const Window, state: Pane.Progress.State, colors: *const vt.RenderState.Colors) vt.color.RGB { return StatusWindow.stateColor(&w.app.theme, state, &colors.palette);}
/// The status badge, over the terminals at their top right: the status/// button, for a window whose title bar the compositor draws and which/// shows no tab bar.fn buildBadge(w: *Window, colors: decor.Colors) !void { w.badge = .{}; const ind = w.title_bar.status orelse return; if (w.title_bar.bar.barHeight() > 0 or w.tab_bar.visible() or w.content.empty()) return; const m = w.app.grid.metrics; const px = struct { fn f(scale: f32, logical: f32) u32 { return @intFromFloat(@round(logical * scale)); } }.f; const bw = StatusButton.width(ind, m.cell_width, px(w.scale, 6)); const bh = m.cell_height + 2 * px(w.scale, 3); const margin = px(w.scale, 6); if (w.content.w < bw + 2 * margin or w.content.h < bh + 2 * margin) return; w.badge = .{ .x = w.content.x + w.content.w - bw - margin, .y = w.content.y + margin, .w = bw, .h = bh }; const r = w.badge; const one: u32 = @max(1, px(w.scale, 1)); const edge = decor.mix(colors.background, colors.foreground, 0.35); const fill = decor.mix(colors.background, colors.foreground, if (w.badge_hover) 0.18 else 0.08); // Fainter with nothing to report, so that it stays out of the way. const idle = ind.count == 0; const edge_shown = if (idle) decor.mix(colors.background, colors.foreground, 0.18) else edge; try w.builder.rect(@floatFromInt(r.x), @floatFromInt(r.y), @floatFromInt(r.w), @floatFromInt(r.h), .{ edge_shown.r, edge_shown.g, edge_shown.b, 255 }); try StatusButton.build(&w.builder, &w.app.grid, &w.app.glyphs, r.x + one, r.y + one, r.w - 2 * one, r.h - 2 * one, ind, colors.foreground, fill);}
/// The height of a progress bar, in logical pixels.const progress_height = 3;
/// `p`'s handle: a short bar across the middle of its top edge, brighter/// with the pointer on it.fn buildHandle(w: *Window, p: *Pane) !void { const h = w.handleRect(p); const fg = p.state.colors.foreground; const bg = p.state.colors.background; const c = decor.mix(bg, fg, if (w.handle_hover) 0.7 else 0.35); const bar_w: f32 = @round(@as(f32, @floatFromInt(h.w)) * 0.5); const bar_h: f32 = @max(2, @round(4 * w.scale)); const x = @as(f32, @floatFromInt(h.x)) + (@as(f32, @floatFromInt(h.w)) - bar_w) / 2; const y = @as(f32, @floatFromInt(h.y)) + @round((@as(f32, @floatFromInt(h.h)) - bar_h) / 2); try w.builder.rect(x, y, bar_w, bar_h, .{ c.r, c.g, c.b, 255 });}
/// A pane being dragged: itself shaded, and the half of the pane under the/// pointer that it would take, outlined and tinted.fn buildPaneDrag(w: *Window, d: PaneDrag) !void { const src = d.pane.rect; const sbg = d.pane.state.colors.background; try w.builder.rect(@floatFromInt(src.x), @floatFromInt(src.y), @floatFromInt(src.w), @floatFromInt(src.h), .{ sbg.r, sbg.g, sbg.b, 140 }); const target = d.target orelse return; const r = target.rect; const z: Pane.Rect = switch (d.side) { .left => .{ .x = r.x, .y = r.y, .w = r.w / 2, .h = r.h }, .right => .{ .x = r.x + r.w - r.w / 2, .y = r.y, .w = r.w / 2, .h = r.h }, .up => .{ .x = r.x, .y = r.y, .w = r.w, .h = r.h / 2 }, .down => .{ .x = r.x, .y = r.y + r.h - r.h / 2, .w = r.w, .h = r.h / 2 }, }; const fg = target.state.colors.foreground; const one: f32 = @max(1, @round(2 * w.scale)); const x: f32 = @floatFromInt(z.x); const y: f32 = @floatFromInt(z.y); const zw: f32 = @floatFromInt(z.w); const zh: f32 = @floatFromInt(z.h); try w.builder.rect(x, y, zw, zh, .{ fg.r, fg.g, fg.b, 40 }); try w.builder.rect(x, y, zw, one, .{ fg.r, fg.g, fg.b, 200 }); try w.builder.rect(x, y + zh - one, zw, one, .{ fg.r, fg.g, fg.b, 200 }); try w.builder.rect(x, y, one, zh, .{ fg.r, fg.g, fg.b, 200 }); try w.builder.rect(x + zw - one, y, one, zh, .{ fg.r, fg.g, fg.b, 200 });}
/// `p`'s grid size, `cols × rows`, in a box at its middle, in its own/// colors; nothing if the box would not fit.fn buildSizeOverlay(w: *Window, p: *Pane) !void { const m = w.app.grid.metrics; const size = p.session.wanted; var text_buf: [32]u8 = undefined; const label = std.fmt.bufPrint(&text_buf, "{d} \u{d7} {d}", .{ size.cols, size.rows }) catch return; var cps: [32]u21 = undefined; var len: usize = 0; var it = (std.unicode.Utf8View.init(label) catch return).iterator(); while (it.nextCodepoint()) |cp| : (len += 1) cps[len] = cp; const pad_x = m.cell_width; const pad_y = m.cell_height / 2; const box_w: u32 = @as(u32, @intCast(decor.cells(cps[0..len]))) * m.cell_width + 2 * pad_x; const box_h: u32 = m.cell_height + 2 * pad_y; if (box_w > p.rect.w or box_h > p.rect.h) return; const x = p.rect.x + (p.rect.w - box_w) / 2; const y = p.rect.y + (p.rect.h - box_h) / 2; const fg = p.state.colors.foreground; const bg = p.state.colors.background; const edge = decor.mix(bg, fg, 0.35); const fill = decor.mix(bg, fg, 0.12); const one: u32 = @max(1, @as(u32, @intFromFloat(@round(w.scale)))); try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 }); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad_x), @floatFromInt(y + pad_y), cps[0..len], .{ fg.r, fg.g, fg.b, 255 });}
/// `t` in a box at the bottom of `p`, in its own colors as the size/// overlay is; cut short to fit, and nothing if the box would not fit.fn buildToast(w: *Window, p: *Pane, t: *const Toast) !void { const m = w.app.grid.metrics; const pad_x = m.cell_width; const pad_y = m.cell_height / 2; const box_h: u32 = m.cell_height + 2 * pad_y; // As far from the bottom as it is tall inside. const margin = pad_y; if (box_h + 2 * margin > p.rect.h or p.rect.w <= 2 * pad_x + 2 * margin) return; const max_cells = (p.rect.w - 2 * pad_x - 2 * margin) / @max(m.cell_width, 1); var cps_buf: [256]u21 = undefined; const cps = decor.fitTitle(t.text(), max_cells, &cps_buf); if (cps.len == 0) return; const box_w: u32 = @as(u32, @intCast(decor.cells(cps))) * m.cell_width + 2 * pad_x; const x = p.rect.x + (p.rect.w - box_w) / 2; const y = p.rect.y + p.rect.h - box_h - margin; const fg = p.state.colors.foreground; const bg = p.state.colors.background; const edge = decor.mix(bg, fg, 0.35); const fill = decor.mix(bg, fg, 0.12); const one: u32 = @max(1, @as(u32, @intFromFloat(@round(w.scale)))); try w.builder.rect(@floatFromInt(x), @floatFromInt(y), @floatFromInt(box_w), @floatFromInt(box_h), .{ edge.r, edge.g, edge.b, 255 }); try w.builder.rect(@floatFromInt(x + one), @floatFromInt(y + one), @floatFromInt(box_w - 2 * one), @floatFromInt(box_h - 2 * one), .{ fill.r, fill.g, fill.b, 255 }); try w.builder.text(&w.app.grid, &w.app.glyphs, @floatFromInt(x + pad_x), @floatFromInt(y + pad_y), cps, .{ fg.r, fg.g, fg.b, 255 });}
fn buildFrame(w: *Window, t: *Tab, ps: []*Pane) !void { try w.builder.reset(); var shown: usize = 0; for (ps) |p| { if (!p.rect.empty()) shown += 1; } const several = shown > 1; const alpha: u8 = @intFromFloat(@round(w.opacity() * 255)); for (ps) |p| { if (p.rect.empty()) continue; const focused = p == t.focused; // Each pane's own background, since each program may set its own; // and, translucent, since then nothing else draws it. if (several or alpha < 255) { const pb = p.state.colors.background; try w.builder.rect(@floatFromInt(p.rect.x), @floatFromInt(p.rect.y), @floatFromInt(p.rect.w), @floatFromInt(p.rect.h), .{ pb.r, pb.g, pb.b, alpha }); } const o = w.gridOrigin(p); // A profile has no search colors of its own. const p_theme = if (p.pane_config) |e| &e.theme else &w.app.theme; try w.builder.build(&p.rows, &p.state, &w.app.grid, &w.app.glyphs, .{ .x = @floatFromInt(o.x), .y = @floatFromInt(o.y), .cursor_on = if (focused) w.cursor_on else true, // What it last showed may be of a size it is no longer. .max_cols = w.gridOf(p.rect).cols, .max_rows = w.gridOf(p.rect).rows, .focused = w.focused and focused, .preedit = if (focused) w.preedit else null, .grapheme_cluster = p.grapheme_cluster, .images = &p.images, .search = .{ .background = p_theme.search.background, .foreground = p_theme.search.foreground, .selected_background = p_theme.search.selected_background, .selected_foreground = p_theme.search.selected_foreground, }, }); if (several and !focused) { const pb = p.state.colors.background; // Faded by as much of the background as there is, so that a // translucent pane is not made less so. const fade: u8 = @intFromFloat(@round(@as(f32, unfocused_fade) * w.opacity())); try w.builder.rect(@floatFromInt(p.rect.x), @floatFromInt(p.rect.y), @floatFromInt(p.rect.w), @floatFromInt(p.rect.h), .{ pb.r, pb.g, pb.b, fade }); } // Lit for its bell: the foreground over it, a fifth of the way. if (p.flash_until != null) { const fg = p.state.colors.foreground; try w.builder.rect(@floatFromInt(p.rect.x), @floatFromInt(p.rect.y), @floatFromInt(p.rect.w), @floatFromInt(p.rect.h), .{ fg.r, fg.g, fg.b, 51 }); } try w.buildScrollbar(p); if (!p.drawn) { p.drawn = true; if (w.app.config().window.resize_overlay == .always) w.showSize(p); } if (p.progress != null and w.app.config().window.progress_bar) try w.buildProgress(p); if (p.size_shown_until != null) try w.buildSizeOverlay(p); // A hidden tab's toast may have run out while it was hidden. if (p.toast) |*pt| if (std.Io.Clock.awake.now(w.app.io).compare(.lt, pt.until)) try w.buildToast(p, pt); try w.buildSearchBar(p); try w.buildConfirmation(p); if (p == w.handle_pane and w.pane_drag == null) try w.buildHandle(p); if (w.link_hover) |h| if (h.pane == p) { const m = w.app.grid.metrics; const fg = p.state.colors.foreground; for (h.rows[0..h.n]) |r| { const x: f32 = @floatFromInt(o.x + @as(u32, r.x0) * m.cell_width); const y: f32 = @floatFromInt(o.y + @as(u32, r.y) * m.cell_height + m.underline_top); const width: f32 = @floatFromInt((@as(u32, r.x1) - r.x0 + 1) * m.cell_width); try w.builder.rect(x, y, width, @floatFromInt(m.underline_thickness), .{ fg.r, fg.g, fg.b, 255 }); } }; }
const colors: decor.Colors = .{ .foreground = t.focused.state.colors.foreground, .background = t.focused.state.colors.background, }; var dbuf: [Tab.max_panes]Pane.Rect = undefined; const divider_color = decor.mix(colors.background, colors.foreground, 0.3); for (t.dividers(&dbuf)) |d| { if (d.empty()) continue; try w.builder.rect(@floatFromInt(d.x), @floatFromInt(d.y), @floatFromInt(d.w), @floatFromInt(d.h), .{ divider_color.r, divider_color.g, divider_color.b, 255 }); }
if (w.pane_drag) |d| try w.buildPaneDrag(d);
const has_profiles = w.app.config().profiles.len > 0; w.title_bar.profiles = has_profiles; w.tab_bar.profiles = has_profiles; var subtitle_buf: [1024]u8 = undefined; var title_buf: [1024]u8 = undefined; try w.title_bar.build(&w.builder, w.app.titleText(), w.title(&title_buf), w.subtitle(&subtitle_buf), colors, w.focused); // A window with no bar still shows where a dragged tab would land: a // line along its top. if (!w.tab_bar.visible() and w.tab_bar.drop_slot != null) { try w.builder.rect(@floatFromInt(w.tab_bar.x), @floatFromInt(w.tab_bar.y), @floatFromInt(w.tab_bar.width), @max(2, @round(3 * w.scale)), .{ colors.foreground.r, colors.foreground.g, colors.foreground.b, 255 }); } if (w.tab_bar.visible()) { w.tab_bar.count = w.tabs.items.len; w.tab_bar.active = w.active; w.tab_bar.leaving = null; if (w.app.tab_drag) |d| if (d.window == w) { w.tab_bar.leaving = std.mem.findScalar(*Tab, w.tabs.items, d.tab); }; var titles_buf: [64][]const u8 = undefined; var progress_buf: [64]?TabBar.Progress = undefined; // Room for each title with its marks before it. var marked_buf: [64][256]u8 = undefined; const n = @min(w.tabs.items.len, titles_buf.len); const show_progress = w.app.config().window.progress_bar; for (w.tabs.items[0..n], titles_buf[0..n], progress_buf[0..n], marked_buf[0..n]) |tb, *tab_title, *pg, *mbuf| { tab_title.* = marked(tb.title(), tabAsking(tb), w.tabRung(tb, .mark), mbuf); // In the shown pane's palette, as the bar is in its colors: a // hidden tab's panes may never have been drawn. pg.* = if (!show_progress) null else if (tabProgress(tb)) |pr| w.progressSpan(pr, &t.focused.state.colors) else null; } try w.tab_bar.build(&w.builder, &w.app.grid, &w.app.glyphs, titles_buf[0..n], progress_buf[0..n], colors, w.focused); } try w.buildBadge(colors); try w.buildProfileSelector(colors);}
/// Where the cursor is, in logical pixels, for an input method.fn cursorRect(w: *const Window) ?ev.Rect { const p = w.pane(); const c = p.state.cursor.viewport orelse return null; const m = w.app.grid.metrics; const o = w.gridOrigin(p); const x: f32 = @floatFromInt(o.x + @as(u32, c.x) * m.cell_width); const y: f32 = @floatFromInt(o.y + @as(u32, c.y) * m.cell_height); return .{ .x = @intFromFloat(x / w.scale), .y = @intFromFloat(y / w.scale), .width = @intFromFloat(@as(f32, @floatFromInt(m.cell_width)) / w.scale), .height = @intFromFloat(@as(f32, @floatFromInt(m.cell_height)) / w.scale), };}