Skip to content

arca-gui migrates from egui to GPUI and redesigns the window with GPUI Kit - #1

Open
Villoh wants to merge 43 commits into
mainfrom
gpui-kit-redesign
Open

arca-gui migrates from egui to GPUI and redesigns the window with GPUI Kit#1
Villoh wants to merge 43 commits into
mainfrom
gpui-kit-redesign

Conversation

@Villoh

@Villoh Villoh commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Migrate arca-gui from egui/eframe to GPUI and redesign the GPUI surface on top of
GPUI Kit. This covers phases 1 through 7 of the plan
added in docs/plans/migration-to-gpui.md.

Intentionally a draft: the GPUI window is already standing and looks good,
but there are still things to polish and platform validation to complete. The
list is below, under “What remains”.

cargo run -p arca-gui still launches the egui window. The GPUI window is
opt-in: cargo run -p arca-gui --features gpui. None of this changes what
users get yet.


Why

arca-gui was a ~4,600-line window in src/main.rs with business logic and
egui rendering interleaved. The goal is not just to change toolkits, but to
keep state out of the type that the toolkit renders.

What changes, layer by layer

1. State is separated from the toolkit

An AppController now owns the inputs, current folder, history, selection,
filter, sort order, jobs, and pending dialogs. It is driven through
AppAction instead of egui::Context.

The workers and message channel are unchanged: no disk or compression
operation touches the UI thread. That is precisely what allows two possible
windows to share the same logic, and what makes this PR incremental rather
than a big bang.

2. GPUI comes from GPUI Kit, not a Zed revision

The original plan pinned zed-industries/zed@3384317. That has been removed:

  • gpui-component is built against gpui-pre ^0.3.
  • Keeping the git revision left two copies of GPUI in the dependency graph,
    which do not link together.

The solution is to rename the packages in Cargo.toml, so not a single
use in the code changes
:

gpui = { package = "gpui-pre", version = "0.3", default-features = false, optional = true }
gpui-component = { version = "0.6", optional = true }
gpui-kit-assets = { version = "0.6", optional = true }

Resulting unified graph: gpui-pre 0.3.4 + gpui-base 0.6.0 +
gpui-component 0.6.0. Reproducibility comes from Cargo.lock, which is no
longer in .gitignore — a pin that is not versioned is not a pin.

spikes/gpui keeps the original revision: it records what was validated in
G1 and is not rewritten retroactively.

3. Monochrome light and dark theme (arca-gui/src/gpui_theme.rs)

Six grays per mode, hue 225 (the night blue from brand/BRAND.md) at 8–12%
saturation, inverted between light and dark.

Dark Light
background #0E1014 #FAFAFB
surface #14161B #FFFFFF
raised #1B1E25 #F3F4F6
border #262A33 #E3E5EA
text #E4E6EB #14161B
muted #8B909C #666B76

There is no accent color. The selected row, keyboard cursor, and focus
ring use the text color at different strengths (12%, 100%, 70%). Contrast is
guaranteed by construction rather than by a table someone has to maintain: if
the text is readable, the selection is visible.

The only exceptions are danger and warning. They are not decoration — they
distinguish “extracted” from “not extracted”, and anyone scanning before
reading needs to be able to see that distinction. They remain at the lowest
chroma that is still legible as red and amber on both backgrounds.

The tokens are the ones from gpui-component; Arca does not add its own
token layer
, it only specifies the value of each token.

Radius 4/6 px, row height 26 px, base font size 13 px.

4. Layout redesign

The reference is Nohrs (the same problem:
a file explorer) with the density of DBFlux.

Before: a stack of floating strips in p_3 + gap_3. Now: full-bleed regions
separated by 1 px lines, with padding inside each bar so every divider spans
the full width.

┌─ actions (40 px) ────────────────────────── [filter] ──┐
├─ ← → ↑ │ path (34 px) ─────────────────────────────────┤
│ folders  │  full-bleed table                           │
│ (224 px) │                                              │
├──────────┴──────────────────────────────────────────────┤
│ file summary                 N visible │ M selected      │
└─────────────────────────────────────────────────────────┘
  • Sidebar with the folder tree (gpui_component::sidebar). It is content,
    not chrome: until now, reaching a deeply nested file meant double-clicking
    down and navigating back up. The current folder branch opens automatically;
    the rest stays closed. The tree is cached by (file path, entry count) —
    rebuilding it in render meant walking all names 60 times per second during
    extraction.
  • Status bar attached to the bottom (gpui_component::status_bar), with
    the summary on the left and counters on the right. The counters used to live
    in the middle of the navigation row, pushing the breadcrumbs against the
    right edge.
  • Borderless buttons, with the background appearing only under the pointer.
    Seven outlined boxes read as seven competing things.
  • Icon arrows (IconName, via gpui-kit-assets) instead of ‹ › ↑.
  • Floating menus: the overflow menu used to be rendered in flow and pushed
    half the window down.
  • Full-bleed table, with no border or radius of its own, a fixed header,
    and alternating stripes.

5. Accessibility

What was already there is preserved and not downgraded: AccessKit roles,
visible focus, keyboard navigation, focus trapping in modals, and the
background subtree without roles while an overlay has focus. Icon buttons keep
their accessible names as words.


Verification

  • cargo test --workspace — green (default build, egui).
  • cargo test -p arca-gui --features gpui — 38/38.
  • cargo fmt -p arca-gui -- --check — clean.
  • Visual inspection on Windows with a nested test ZIP: folder tree, navigation,
    table, status bar, dark theme.

Two new tests in gpui_theme.rs:

  • WCAG contrast for every ink color on each of the three backgrounds, in
    both modes. With no accent, the palette is the accessibility, so this is
    the test that matters.
  • Distinct, opposite palettes: two grays that are the same gray, or a mode
    that stops being the inverse of the other. These are copy-and-paste slips that
    compile and do not show up in a diff of thirty hex literals.

I wrote a third test that failed (it assumed the three backgrounds are ordered
in the same direction in both modes). That is false: in light mode a panel is
raised toward white and a hover is tinted away from it. Rather than tuning
the threshold until it passed, I replaced it with the thing that can actually
break.


What remains

That is why this is a draft.

  1. Internal widgets are still hand-rolled. They need to be replaced with
    the ones from gpui-component:
    • Input — removes the roughly 400 lines of FilterInput and its UTF-16
      IME contract. This is the largest pending deletion.
    • Modal/Root, Popover, Table, Notification.
  2. Menus float with absolute and fixed offsets (top(38.) right(232.)),
    rather than being anchored to their trigger. They break if the filter width
    or font size changes. The correct solution is Popover.
  3. There is no settings dialog in the GPUI surface, which would allow the
    theme and language to be changed without editing gui.conf. For now the
    preference is read at startup and System follows the desktop.
  4. Incomplete platform matrix. Only Windows GNU has been validated. Linux
    and macOS are still unmarked in docs/plans/gpui-spike-baseline.md, and
    they should not be marked without a real test.
  5. Screen reader not yet validated with Narrator/NVDA on the GPUI window.
    The plan says any accessibility limitation blocks removing egui, so this is
    required before phase 8.
  6. egui is still included. Phase 8 (removing eframe, egui,
    egui_extras) is not done, and should not be done until 4 and 5 are closed.

Notes for reviewers

  • Three button labels were reconstructed. During the work I corrupted 15
    multiline dialog_button calls with a bad regex. The on_click bodies
    survived intact, and modal_focus_targets/modal_enter pin the exact id and
    focus handle of each button, so the reconstruction is deterministic except
    for the visible text of three: "Set Password"/"Unlock",
    "Keep Both"/"Keep Both Always", and "Delete". They deserve a look.
  • Commit 1 contains the spike and the plan; commit 2 contains the code. They
    are not split further because any intermediate cut inside gpui_shell.rs
    produces a commit that does not compile.
  • Settings::load already reads the columns key, which was a parity defect
    noted in G1. The gui.conf format does not change.

Villoh and others added 3 commits September 8, 2026 21:17
La rama anterior se hizo sobre c4c0354 y main se movio 18 commits por
debajo: renombrar dentro del archivo, el visor, la vista plana, los
grupos por mascara, las columnas y el modo oscuro en blanco y negro. Los
dos lados reescribieron main.rs desde la misma base, asi que el merge
salieron 46 hunks que son el mismo conflicto repetido: la rama renombro
todos los accesos al estado y main escribio mil seiscientas lineas
nuevas contra la forma vieja.

Resolver eso no es elegir un lado, es rehacer la extraccion encima del
main de hoy, y se hace asi a proposito: el compilador comprueba la
transformacion. Un hunk mal resuelto compila; un self.archive que se
escape de la reescritura, no.

Este commit trae solo lo que no toca main.rs y por tanto no choca: el
spike, los planes, Cargo.lock fuera de .gitignore y las dependencias de
GPUI Kit. gpui_shell.rs y gpui_theme.rs vienen en el arbol pero main.rs
todavia no los declara, asi que no se compilan: la ventana GPUI se
enciende en el commit siguiente, cuando exista AppController.

El analisis de esa extraccion --que campo va donde, cuales de los 57
metodos se mueven, y el unico sitio que no es mecanico-- esta en
docs/plans/portar-controlador-a-main.md para que no viva en la cabeza de
nadie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Faltaba lo que una sesion nueva no puede deducir del codigo: en que rama
esta cada cosa, y sobre todo que la rama vieja no se borra porque su
main.rs es la implementacion de referencia de AppState, AppController y
AppAction. Rehacerlos desde cero seria trabajo tirado.

Tambien la lista de comprobacion manual. cargo test no toca la UI de
egui, asi que si la extraccion se come el visor o los grupos por
mascara, la suite pasa verde igual y nadie se entera.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Villoh and others added 26 commits September 8, 2026 21:59
# Conflicts:
#	arca-gui/Cargo.toml
#	arca-gui/src/main.rs
El shell GPUI llevaba 149 de los 161 textos en ingles fijo dentro del codigo: en espanol se veia media ventana sin traducir y un lector de pantalla leia los nombres de las regiones en ingles. Ahora sale todo de i18n.rs.

Diecisiete cadenas nuevas, las que GPUI necesita y egui no tenia: los nombres de las regiones que solo existen aqui -- barra de acciones, estado, progreso, contenido -- y los estados vacios, que en egui eran una sola frase y aqui distinguen archivo sin entradas, carpeta vacia y filtro sin coincidencias.
Idioma, tema, y el formato, compresor, nivel y subcarpeta con que se crea un archivo nuevo. Hasta ahora la superficie GPUI leia gui.conf al arrancar y no habia forma de tocarlo sin editar el fichero a mano.

Cada preferencia es una fila de botones con el actual relleno, no un desplegable: hay tres como mucho de cada, y una lista asi de corta cuesta mas abrirla que leerla. El teclado y el raton pasan por settings_activate, que es una sola funcion, para que Enter y el clic no puedan acabar haciendo cosas distintas.
Solo habia Ctrl+F, C, X y V. Ahora tambien Ctrl+O, Ctrl+N, Ctrl+E, Ctrl+T, Ctrl+I, Ctrl+Shift+C, Alt+W, F5, F1 y Escape, y una ventana F1 que los lista.

Un solo manejador en la raiz en vez de una docena de acciones GPUI con sus bindings: cada atajo es la misma forma -- una tecla, una guarda y una accion ya escrita -- y una tabla de ellos se lee de una vez. La lectura de la tecla sale a shortcut_for, que no toca estado, para poder comprobar sin ventana que Ctrl+O sigue valiendo con el cursor dentro del filtro y que F5 no.
La superficie GPUI no tenia menu contextual: abrir, extraer la seleccion, extraer aqui, probar la seleccion, quitar, copiar, cortar, pegar, copiar nombres y seleccionar todo solo estaban en la barra, en el menu de overflow o en una tecla que habia que saberse.

Cada entrada nombra su atajo, para que el menu se deje de usar solo. Copiar, cortar y pegar se quedan fuera donde el escritorio no tiene portapapeles de ficheros, en vez de aparecer en gris: una entrada que no puede hacer nada nunca es peor que no tenerla. Pulsar donde no hay menu lo cierra; el menu esta occluded, asi que sus propios clics no llegan al fondo.
El ancho estaba clavado en el codigo, uno por columna, y no se guardaba. Ahora sale de Settings::widths, el mismo campo de gui.conf que llevaba usando la ventana egui, asi que una anchura puesta en una superficie aparece en la otra.

El tirador va absoluto dentro de la celda de cabecera, no como elemento de la fila: un separador con ancho propio correria cada cabecera unos pixeles respecto a la columna que nombra. El raton se sigue en la ventana y no en la tira, porque el puntero se sale de seis pixeles enseguida. Doble clic ajusta la columna a lo que lleva dentro.

El nombre sigue siendo elastico en vez de tener ancho fijo: es lo que hace que la lista llegue al canto derecho.
Todo el archivo de una vez, sin carpetas, que es como se busca algo cuando no se sabe donde esta. El controlador ya lo tenia en visible_rows leyendo settings.flat; lo que faltaba era la forma de encenderlo. Al encenderla se enciende la columna Ruta si estaba apagada, porque una lista sin carpetas encima tiene que decir de donde sale cada nombre.
Faltaba todo lo que escribe: anadir ficheros, nueva carpeta, renombrar, deshacer, guardar una copia, contrasena por defecto y seleccionar por mascara. La superficie GPUI solo sabia abrir, extraer y borrar.

undo_last, drop_undo y reread_names se van de la vista egui al AppController. No tienen nada de un toolkit dentro y las dos superficies las ofrecen; tenerlas colgando de ArcaApp era la razon por la que GPUI no podia deshacer.

Los cuatro dialogos que piden un texto comparten un solo campo con el nombre que le pone el dialogo abierto, porque son modales y excluyentes entre si: un campo con tres nombres es un campo, no tres que estan vacios casi siempre. El mismo motivo por el que el volcado de lo tecleado sale a push_to_owner: habia dos copias identicas y un campo nuevo se olvidaba en una de ellas.
Los recientes cuelgan del menu, diez como mucho, por ruta y no por nombre para que dos que se llaman igual se distingan, con su escoba al final.

La pagina de codigo se va a los ajustes en vez de a un submenu: es una preferencia guardada en gui.conf, y ese es el sitio de las preferencias guardadas. Cambiarla relee los nombres del archivo abierto en el sitio, sin volver a abrirlo.
F3, o el menu de la fila, abre lo que hay dentro del archivo sin sacarlo: como texto, como hex o como la imagen que es. El controlador ya sabia leerlo -- view_entry estaba en AppController -- y GPUI no tenia donde ponerlo.

El texto y el hex van por uniform_list, asi que solo se maquetan las lineas en pantalla y un log de un millon de lineas se abre igual de rapido que una nota de tres. La imagen se decodifica una vez y se guarda con el nombre del que salio: darle a GPUI una Image nueva por fotograma seria decodificar una foto de treinta megas sesenta veces por segundo.

La pestana de imagen solo aparece cuando hay imagen, y la fila de una carpeta no ofrece visor: una pestana que dice imagen sobre un fichero de texto es una pestana que miente.
El merge f10f5b4 se quedo con el main.rs de la rama tal cual y con el i18n.rs de main tal cual, asi que la ventana egui perdio features que si estaban en 0.6.6. moving_word, que nadie usaba en ninguna de las dos superficies, era la senal.

Vuelven Job::Move, moved_name con su prueba de las dos escrituras de barra, y move_into, ahora en AppController y no colgando de la vista egui. Con ellos vuelve tambien el apunte de deshacer en run_job: sin el, state.undo no se escribia nunca y Ctrl+Z y su entrada de menu estaban apagados para siempre.

En GPUI el gesto es el mismo que en egui: mientras la seleccion esta en el aire no se decide nada. Si cae sobre una carpeta del archivo, se mueve ahi; si sale de la lista, la coge arca-drag. El arrastre nativo no puede empezar antes, porque en cuanto empieza el sistema se queda el puntero y ya no hay vuelta a la lista.
Lo otro que se llevo por delante el merge f10f5b4. arca-net seguia en Cargo.toml sin que nadie lo llamase, y update_ready, check_updates, update_tampered y update_installing estaban en i18n.rs sin usar: esa era la senal.

Vuelve el aviso entero -- preguntar una vez al arrancar, en un hilo y sin decir nada; la entrada arriba del menu con un punto en el boton para que se vea desde fuera; bajar el instalador comprobando el SHA-256 contra las sumas publicadas al lado; y dejar que Inno cierre y reabra Arca. Una copia sacada del .zip no se actualiza sola, porque son ficheros sueltos en una carpeta que eligio alguien: a esa se le ofrece la pagina. Solo se aceptan direcciones https de nuestro propio repositorio.

Y con ello Settings::save, que construia las lineas de flat, tree, page, window y recent en un String que luego tiraba. Seis ajustes se leian al arrancar y no se escribian nunca: por eso la lista de recientes salia siempre vacia. El texto del fichero sale ahora a Settings::text y la prueba lo lee de vuelta sin tocar disco, que es la unica forma de que no se vuelva a romper en silencio.
La tercera que se llevo el merge, y con ella lo que nadie usaba en i18n.rs: elapsed_word, time_left, pause_word, resume_word, paused_word y stopping.

Con ella vuelve tambien Cancelar, que estaba muerto: AppAction::CancelJob era un brazo vacio y el notify del worker devolvia true siempre, asi que el boton no paraba nada. Ahora hay dos banderas -- parar y esperar -- que el worker lee al acabar cada entrada, que es el unico momento en que no esta en mitad de algo. Pausar suelta al final de una entrada, no de un byte: un fichero empezado tiene que terminar. Parar pasa aunque este en pausa, para no tener que reanudar algo solo para poder abandonarlo.

Las banderas se renuevan por trabajo en vez de bajarse: un hilo al que se le dijo que parase puede seguir de salida, y no debe leer la bandera que mira el trabajo siguiente.

El trabajo vuelve a distinguir si se lanzo desde la lista -- panel encima, con el fondo atenuado -- o desde el Explorador, que es la ventana entera porque no hay lista detras a la que volver. En GPUI la tira de progreso gana el reloj, la estimacion y el boton de pausa.
Un trabajo abandonado decia en rojo lo que dijese el error de dentro. No hay nada mal en el archivo: la reescritura se rindio antes de cambiar nada, y eso es lo que dice ahora, en el color de lo que solo es una noticia. Era la ultima cadena de i18n.rs que no usaba nadie.
Los dos gestos de puntero que faltaban. La lista de GPUI es virtualizada, asi que sobre que fila esta el raton es aritmetica con el desplazamiento y no un rectangulo que alguien haya guardado: eso sale a row_under, que se comprueba sin ventana porque una fila de diferencia ahi seleccionaria todo corrido un sitio.

La goma sigue la regla del Explorador, que es la unica que deja los dos gestos en el mismo boton: pulsar sobre algo ya marcado y tirar lo lleva a otro sitio, pulsar en cualquier otro lado y tirar dibuja una seleccion nueva. Ctrl suma a lo que habia. Pasado un borde la lista corre detras del puntero, porque si no una seleccion nunca podria ser mas larga que la ventana.

La rueda pulsada deja un ancla y la lista corre hacia el puntero, mas rapido cuanto mas lejos, con wheel_speed, que es la misma funcion y las mismas pruebas que usa egui. La suelta cualquier otro boton, la propia rueda girando o Escape.

Dos diferencias con egui, a proposito: el paso lo da el tick de 100 ms que ya tenia el shell en vez de un fotograma, y el ancla es un anillo sin las flechas que Windows dibuja sobre el puntero, porque eso pedia esconder el cursor del sistema.
Se veia OpenEnter, ViewF3, RemoveSupr. Las entradas se construian con una tabulacion dentro del texto, que es como egui dibuja una columna a la derecha; GPUI maqueta texto y ahi una tabulacion no es nada, asi que el atajo quedaba soldado a la palabra.

El atajo pasa a ser un hijo aparte, alineado a la derecha y en la tinta apagada, porque es una forma de llegar y no una segunda cosa que leer. Como ya no hay que meterlo en la cadena, lo tienen todas las entradas que lo tengan y no solo las tres del portapapeles: probar, seleccionar todo, invertir, soltar, deshacer y contrasena por defecto tambien lo dicen ahora.

Y vuelven las rayas que agrupaban ese menu en egui: mirar, cambiar, mover por el portapapeles y trabajar sobre la seleccion son cuatro cosas distintas, y doce entradas seguidas son un muro.
La leyenda nativa se queda transparente y la dibuja TitleBar de GPUI Kit, con los mismos tokens que todo lo que hay debajo: el arrastre, el doble clic para maximizar y los tres botones de la ventana los lleva ella. Dentro va el nombre de lo que esta abierto, que es para lo que sirve una barra de titulo.

Nada pulsable ahi dentro a proposito: la barra es la zona de arrastre, y un control dentro de ella movería la ventana en cuanto la mano temblase de camino a pulsarlo.

Dos cosas que el alto nuevo movia. El menu de overflow flota en coordenadas de ventana, asi que ahora cuenta la barra o se dibujaba encima de la fila de botones en vez de debajo. Y los dos velos que tapan el fondo -- los dialogos y el selector de ficheros del sistema -- empiezan bajo la barra en vez de cubrirla: los tres botones de la ventana no son fondo, y un dialogo que los tapase seria un dialogo que no deja cerrar la ventana que hay detras.
… ventana

Dos fallos distintos con la misma cara.

La goma leia el alto de fila de last_item_size.item, que pese al nombre es el tamano del viewport y no el de una fila: dividia por cuatrocientos en vez de por veintiocho, asi que todo punto caia en la primera fila y no se marcaba nada. El alto sale ahora del contenido entre el numero de filas, que es de donde GPUI lo saco, y la division va a row_height con su prueba para que el nombre no vuelva a enganar a nadie.

Y arrastrar fuera no hacia nada porque AppController::drag_out era un cuerpo vacio: la implementacion de Windows con arca-drag tambien se la comio el merge f10f5b4, y arca-drag llevaba desde entonces en Cargo.toml sin que la llamase nadie. Vuelve entera, sin extraer nada al empezar: al shell se le da una lista de nombres y tamanos y va pidiendo un fichero cada vez mientras suelta, asi que un arrastre del que uno se arrepiente no cuesta nada.

El disparo pasa de la fila a la ventana. on_drag_move se dispara con cualquier movimiento, y comparando contra el rectangulo de la fila el arrastre nativo empezaba en cuanto el puntero llegaba a la fila de al lado -- que no es salir de la lista, y ademas hacia imposible soltar sobre una carpeta. Ahora se mira una sola vez, contra la lista entera.
Dos costes, los dos por movimiento del raton, que es a cien hercios.

visible_rows ordenaba llamando a to_lowercase() dentro del comparador: dos String por comparacion, o sea unas treinta mil asignaciones por lista de mil quinientas filas. Ahora se pliega caracter a caracter con Iterator::cmp, sin asignar nada. Eso lo paga todo el que ordene, tambien el repintado normal.

Y la lista se construia entera dos o tres veces por evento. La goma se la queda congelada al empezar el gesto -- lo que marca no puede cambiar de donde lo marca, y con un boton bajado no hay nada mas que la cambie -- y lo unico que las demas necesitaban de ella era cuantas filas hay, que ahora lo apunta el repintado, que es el que ya lo sabia.
No habia perfil dev, asi que cargo run compilaba todo a opt-level 0: GPUI entero, su motor de layout, el modelador de texto y el rasterizador, que rehacen su trabajo en cada fotograma. Eso no es un poco mas lento, es la diferencia entre una lista que sigue al puntero y una que va a tirones al arrastrar y al marcar.

Las dependencias pasan a 3 tambien en depuracion: no son codigo de Arca y nadie las depura. El codigo de Arca a 1, que es lo que necesitan ordenar la lista y recorrer las entradas para no notarse, y a ese nivel el depurador sigue parando donde se le dice.

La primera compilacion despues de esto rehace el arbol entero -- seis minutos y medio aqui -- y a partir de ahi un cambio en arca-gui vuelve a ser ocho segundos.
@Villoh Villoh changed the title arca-gui pasa de egui a GPUI, y la ventana se rediseña con GPUI Kit arca-gui migrates from egui to GPUI and redesigns the window with GPUI Kit Sep 11, 2026
@Villoh
Villoh marked this pull request as ready for review September 11, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant