Chemica I: a GTK-based PubChem wiki app with 3D molecular viewing
The stack
Chemica is a GTK4 desktop application written in D using GtkD. It searches PubChem by compound name, renders a 3D molecular model, surfaces chemical properties and identifiers, and pulls structured articles from Wikipedia, PsychonautWiki, PubMed, and PMC. Dosage data comes from PsychonautWiki with strict similarity-matching fallbacks. A local AI layer (Intuit) can synthesize conflicting sources into a single coherent article.
This article covers the frontend: GTK architecture, the 3D conformer viewer, article rendering, and dosage matching.
GTK4 application shell
The entry point is a standard Gtk.Application with a CSS provider for custom styling. The window uses a Gtk.Stack to switch between three views: home (search), article (compound details), and settings.
class ChemicaApp : Application
{
private:
CssProvider cssProvider;
void applyCss()
{
cssProvider = new CssProvider();
if (cssPath.exists)
cssProvider.loadFromString(cssPath.readText);
else
writeln("Warning: CSS file not found at " ~ cssPath);
StyleContext.addProviderForDisplay(
Display.getDefault(),
cssProvider,
STYLE_PROVIDER_PRIORITY_APPLICATION);
}
void onActivate()
{
applyCss();
ChemicaWindow window = new ChemicaWindow(this);
window.present();
}
public:
this()
{
super("org.chemica.pubchem", ApplicationFlags.DefaultFlags);
connectActivate(&onActivate);
}
}
void main(string[] args)
{
ChemicaApp app = new ChemicaApp();
app.run(args);
}
Search and compound resolution
The home view is a SearchEntry plus a ListBox of results. When the user types a compound name, the app queries PubChem through Akashi and populates rows with the compound name, CID, and source label. Every search returns a primary match plus up to four structurally similar compounds so the user can pivot when a query is ambiguous.
Box buildResultRow(Compound compound, bool isPrimary = false)
{
Box row = new Box(Orientation.Horizontal, 8);
row.addCssClass("search-result-row");
if (!isPrimary)
row.addCssClass("similar-result");
Label title = new Label(compound.name);
title.addCssClass("search-result-title");
title.halign = Align.Start;
title.hexpand = true;
row.append(title);
Label cid = new Label(compound.cid.to!string);
cid.addCssClass("search-result-source");
cid.halign = Align.End;
row.append(cid);
Label src = new Label("PUBCHEM");
src.addCssClass("search-result-source");
src.halign = Align.End;
src.marginStart = 8;
row.append(src);
return row;
}
The 3D molecular viewer
The viewer is a custom DrawingArea that renders a PubChem 3D conformer with Cairo. It supports mouse drag to rotate, scroll to zoom, and hover tooltips that identify the atom under the cursor. The camera is a simple orbit model.
class MoleculeView : DrawingArea
{
package:
Compound compound;
Camera camera;
double lastDragX;
double lastDragY;
void onDraw(CairoContext ctx, int width, int height)
{
drawBackground(ctx);
drawGrid(this, ctx);
if (compound !is null && compound.conformer3D !is null)
drawMolecule(this, ctx, compound.conformer3D, camera);
}
public:
this()
{
addCssClass("molecule-viewer");
setContentWidth(400);
setContentHeight(300);
hexpand = true;
camera = Camera();
setDrawFunc(&onDraw);
GestureDrag drag = new GestureDrag();
drag.connectDragBegin(&onDragBegin);
drag.connectDragUpdate(&onDragUpdate);
addController(drag);
EventControllerScroll scroll = new EventControllerScroll(
EventControllerScrollFlags.Vertical);
scroll.connectScroll(&onScroll);
addController(scroll);
}
}
Rendering happens in painter-order by depth. Atom colors follow the CPK convention and radii scale by van der Waals approximation:
double[3] getColor(Element elem)
{
switch (cast(int)elem)
{
case 1: return [1.0, 1.0, 1.0]; // H - White
case 6: return [0.3, 0.3, 0.3]; // C - Dark gray
case 7: return [0.2, 0.4, 1.0]; // N - Blue
case 8: return [1.0, 0.2, 0.2]; // O - Red
case 15: return [1.0, 0.5, 0.0]; // P - Orange
case 16: return [1.0, 0.9, 0.2]; // S - Yellow
default: return [0.7, 0.5, 0.7]; // Light purple
}
}
void drawMolecule(MoleculeView view, CairoContext ctx,
Conformer3D conformer, Camera camera)
{
Atom3D[] atoms = conformer.atoms;
Bond3D[] bonds = conformer.bonds;
foreach (atom; atoms)
{
double[3] projected = camera.project(atom.x, atom.y, atom.z);
double radius = getRadius(atom.element) * camera.zoom;
drawAtom(ctx, projected[0], projected[1], radius,
getColor(atom.element));
}
foreach (bond; bonds)
{
double[3] a = camera.project(
atoms[bond.atomA].x, atoms[bond.atomA].y, atoms[bond.atomA].z);
double[3] b = camera.project(
atoms[bond.atomB].x, atoms[bond.atomB].y, atoms[bond.atomB].z);
drawBond(ctx, a[0], a[1], b[0], b[1], camera.zoom);
}
}
Article rendering with collapsible panels
Below the viewer, the article view renders fetched text with full Wikitext parsing. Headings are sorted into collapsible panels so the user can navigate dense articles without scrolling through walls of text. The parser is built on Akashi’s Page type, which lazily fetches and exposes structured sections:
void renderArticle(Page page)
{
foreach (section; page.sections)
{
Expander expander = new Expander(section.heading);
expander.addCssClass("article-panel");
Label body = new Label(section.fulltext);
body.wrap = true;
body.halign = Align.Start;
expander.child = body;
articleBox.append(expander);
}
// Preamble (lead paragraph) stays expanded above the panels
Label preamble = new Label(page.preamble);
preamble.wrap = true;
preamble.addCssClass("article-preamble");
articleBox.prepend(preamble);
}
Dosage similarity matching
For psychoactive compounds, dosage data comes from PsychonautWiki. When a compound lacks its own dosage page, Chemica falls back to structural similarity scoring. The match must satisfy all three criteria:
- Tanimoto coefficient for structural similarity > 0.7
- XLogP variance within ±0.5 units
- Molecular weight within ±50 Daltons
struct SimilarityResult
{
double tanimoto;
double xlogpDelta;
double weightDelta;
bool acceptable;
}
SimilarityResult scoreSimilarity(Compound target, Compound candidate)
{
double tanimoto = tanimotoFingerprint(target.fingerprint,
candidate.fingerprint);
double xlogpDelta = abs(target.properties.xlogp -
candidate.properties.xlogp);
double weightDelta = abs(target.properties.weight -
candidate.properties.weight);
return SimilarityResult(
tanimoto,
xlogpDelta,
weightDelta,
tanimoto > 0.7 &&
xlogpDelta <= 0.5 &&
weightDelta <= 50.0
);
}
Local AI synthesis
When multiple sources overlap partially or contradict, Chemica can route the text through a local LLM via Intuit. The prompt is deterministic and structured: it receives the overlapping excerpts, their sources, and instructions to produce a single coherent paragraph with inline citations. This runs entirely on-device, so no data leaves the machine.
string synthesize(Page[] pages, string compoundName)
{
EaseConfig config;
config.system = "You are a scientific editor. Merge the " ~
"provided excerpts into one coherent paragraph. " ~
"Preserve formatting. Cite sources inline.";
EaseRequest request;
request.prompt = buildSynthesisPrompt(pages, compoundName);
auto response = intuit.ease(request, config);
return response.text;
}
What the frontend proved
- GTK4 in D is viable for complex desktop apps if you keep the widget layer thin and push state into plain D classes.
- A custom Cairo renderer is enough for molecular visualization; you do not need WebGL for simple 3D.
- Lazy fetching at the library layer means the UI never blocks on network.
- Separating the GUI from the data layer (Akashi) made it trivial to add a CLI mode later without code duplication.
Continue to Part II → for the Akashi library design: identity resolution, the shared Page type, and how five incompatible APIs become one code path.