···25252626## Why?
27272828-[Fortransky](https://github.com/FormerLab/fortransky) — our FORTRAN Bluesky client — shipped a few days before Atmosphere 2026. The AT Proto crowd found out that someone had written a Bluesky client in a language from 1957. This was considered funny. We considered it a mandate :)
2828+[Fortransky](https://github.com/FormerLab/fortransky) — our FORTRAN 77 Bluesky client — shipped a few days before Atmosphere 2026. The AT Proto crowd found out that someone had written a Bluesky client in a language from 1957. This was considered funny. We considered it a mandate :)
29293030-FORTRAT-F90 is the follow-up, our inhouse AT Ptoto lexicon explorer now open sourced. If Fortransky proved you *could* post to Bluesky from Fortran, FORTRAT-F90 proves you can also explore the entire AT Protocol schema from Fortran while watching a force simulation settle in real time.
3030+FORTRAT-F90 is the follow-up, our in-house AT Proto lexicon explorer, now open sourced. If Fortransky proved you *could* post to Bluesky from Fortran, FORTRAT-F90 proves you can also explore the entire AT Protocol schema from Fortran while watching a force simulation settle in real time.
31313232The name is FORTRAN + AT Protocol. FORTRAN identifiers were limited to 6 characters. We used 6. We showed restraint.
3333···39394040FORTRAT-F90 fetches all of them, builds a graph where nodes are lexicon types and edges are references between them, and renders it. The dense blue-green cluster in the middle is `app.bsky.*`. The orbiting yellow nodes are `com.atproto.*`. The purple ones are `tools.ozone.*`. The force simulation pulls related types together and pushes unrelated ones apart, so the layout roughly reflects the actual structure of the protocol.
41414242-It updates every 150ms. The nodes drift.
4242+Nodes show their actual lexicon name — `post`, `like`, `follow`, `getProfile`. The simulation settles after a few seconds and the graph holds still.
43434444---
4545···106106107107## Recommended terminal size
108108109109-120×40 minimum. The wider the better. The graph breathes at 200+ columns
109109+120×40 minimum. The wider the better. The graph breathes at 200+ columns.
110110111111Works in cool-retro-term (phosphor green preset, obviously) and in any modern terminal. The screenshots from cool-retro-term looked better. The screenshots from a normal terminal are more readable. Both are valid choices and we will not judge you.
112112···114114115115## Relation to Fortransky
116116117117-[Fortransky](https://github.com/FormerLab/fortransky) is a FORTRAN Bluesky client. It posts. It reads timelines. It has a Rust decoder for the firehose and an x86-64 assembly decoder for fun. It is arguably the most over-engineered Bluesky client in existence...
117117+[Fortransky](https://github.com/FormerLab/fortransky) is a FORTRAN 77 Bluesky client. It posts. It reads timelines. It has a Rust decoder for the firehose and an x86-64 assembly decoder for fun. It is arguably the most over-engineered Bluesky client in existence...
118118119119FORTRAT-F90 is Fortran 2018. It uses allocatable strings, `iso_c_binding`, and modern modules. By comparison it is practically contemporary software.
120120
+31-14
src/main.f90
···991010 type(lex_graph_t) :: graph
1111 type(app_state_t) :: state
1212- integer :: key, cols, rows
1212+ integer :: key, cols, rows, frame_skip
1313+ logical :: dirty
13141415 ! ── Init state ──
1516 state%groups = .true. ! all ns groups visible
···43444445 state%mode = MODE_GRAPH
4546 state%status_msg = 'READY'
4747+ frame_skip = 0
46484749 ! ── Main event loop ──
4850 do while (.true.)
5151+ dirty = .false.
49525050- ! ── Simulation tick ──
5151- call sim_tick(graph, state%graph_w, state%graph_h)
5353+ ! ── Simulation tick (only while alpha > 0) ──
5454+ if (alpha > 0.0d0) then
5555+ call sim_tick(graph, state%graph_w, state%graph_h)
5656+ dirty = .true.
5757+ end if
52585359 ! ── Read input (non-blocking) ──
5460 key = tui_read_key()
5555- if (key /= 0) call handle_key(key, graph, state)
6161+ if (key /= 0) then
6262+ call handle_key(key, graph, state)
6363+ dirty = .true.
6464+ end if
56655757- ! ── Render ──
5858- call render_clear(state%term_w, state%term_h)
5959- call render_ruler(state%term_w, state%term_h)
6060- call render_header(state%term_w, state%term_h, state%graph_w + 1)
6161- call render_graph_pane(graph, state, state%graph_w, state%term_h, 2)
6262- call render_inspect_pane(graph, state, state%graph_w + 2, state%term_w, state%term_h)
6363- call render_status(state, graph, state%term_w, state%term_h)
6464- call render_flush(state%term_w, state%term_h)
6666+ ! ── Render only when something changed, or every 8 frames as keepalive ──
6767+ frame_skip = frame_skip + 1
6868+ if (dirty .or. frame_skip >= 8) then
6969+ frame_skip = 0
7070+ call render_clear(state%term_w, state%term_h)
7171+ call render_ruler(state%term_w, state%term_h)
7272+ call render_header(state%term_w, state%term_h, state%graph_w + 1)
7373+ call render_graph_pane(graph, state, state%graph_w, state%term_h, 2)
7474+ call render_inspect_pane(graph, state, state%graph_w + 2, state%term_w, state%term_h)
7575+ call render_status(state, graph, state%term_w, state%term_h)
7676+ call render_flush(state%term_w, state%term_h)
7777+ end if
65786666- ! ── Frame sleep 200ms ──
6767- call usleep_f(200000)
7979+ ! ── Frame sleep: faster while settling, slower when idle ──
8080+ if (alpha > 0.0d0) then
8181+ call usleep_f(100000) ! 100ms while sim running (~10fps)
8282+ else
8383+ call usleep_f(50000) ! 50ms when idle — responsive to keys
8484+ end if
6885 end do
69867087contains
+86-64
src/render.f90
···8383 end subroutine
84848585 ! Draw a node sigil at position
8686- subroutine render_node(cx, cy, sigil, color, selected, w, h)
8686+ subroutine render_node(cx, cy, lbl, color, selected, w, h)
8787 integer, intent(in) :: cx, cy, w, h
8888- character(len=3), intent(in) :: sigil
8989- character(len=*), intent(in) :: color
8888+ character(len=*), intent(in) :: lbl, color
9089 logical, intent(in) :: selected
9191- character(len=5) :: label
9292- integer :: i
9393-9090+ integer :: i, llen, start_col
9191+ character(len=12) :: display
9292+ llen = min(len_trim(lbl), 10) ! cap at 10 chars
9493 if (selected) then
9595- label = '['//sigil//']'
9494+ display = '['//lbl(1:llen)//']'
9595+ llen = llen + 2
9696 else
9797- label = ' '//sigil//' '
9797+ display = lbl(1:llen)
9898 end if
9999-100100- do i = 1, 5
101101- call render_set(cx - 2 + i, cy, label(i:i), color, selected, w, h)
9999+ ! Centre the label on cx
100100+ start_col = cx - llen/2
101101+ do i = 1, llen
102102+ call render_set(start_col + i - 1, cy, display(i:i), color, selected, w, h)
102103 end do
103104 end subroutine
104105···114115 end do
115116 end subroutine
116117117117- ! Flush frame to terminal — clear screen then write only non-space cells
118118+ ! Flush frame — build entire output as one buffer, write atomically (no flicker)
118119 subroutine render_flush(w, h)
119119- use fortrat_tui, only: fortrat_write_at, fortrat_flush, fortrat_clear_screen
120120+ use fortrat_tui, only: fortrat_write_buf, fortrat_flush
120121 use iso_c_binding
121122 integer, intent(in) :: w, h
122122- integer :: c, r, clen
123123- character(len=512) :: cell_str
124124- integer(c_int) :: ci, ri
123123+ ! Frame buffer: enough for clear + all cells
124124+ ! Each cell: ~20 bytes (cursor pos + color + char + reset)
125125+ ! Max cells: 300*100 = 30000 * 20 = 600KB — use allocatable
126126+ character(len=1), allocatable :: fbuf(:)
127127+ integer :: fbuf_size, fpos
128128+ integer :: c, r, cl
129129+ character(len=32) :: pos_seq
130130+ integer :: pos_len
131131+132132+ fbuf_size = 9 + w * h * 16 ! home seq + per cell: color+bold+char+reset ~16 bytes
133133+ allocate(fbuf(fbuf_size))
134134+ fpos = 0
125135126126- ! Clear screen once per frame
127127- call fortrat_clear_screen()
136136+ ! Start with: hide cursor, home cursor only (no clear — prevents scroll)
137137+ call fbuf_append(fbuf, fpos, char(27)//'[?25l', 6) ! hide cursor
138138+ call fbuf_append(fbuf, fpos, char(27)//'[H', 3) ! cursor home row 1 col 1
128139129129- ! Write only non-space cells individually — each as ESC[row;colH + color + char
140140+ ! Write all cells — including spaces — so previous frame is fully overwritten
130141 do r = 1, h
131131- do c = 1, w
132132- if (cur_frame(c,r)%ch == ' ' .and. len_trim(cur_frame(c,r)%color) == 0) cycle
142142+ ! Move to start of row
143143+ write(pos_seq, '(a,i0,a,i0,a)') char(27)//'[', r, ';1H'
144144+ pos_len = len_trim(pos_seq)
145145+ call fbuf_append(fbuf, fpos, pos_seq(1:pos_len), pos_len)
133146134134- ! Build: color + bold + char
135135- clen = 0
147147+ do c = 1, w
148148+ ! Color
136149 if (len_trim(cur_frame(c,r)%color) > 0) then
137137- block
138138- integer :: cl
139139- cl = len_trim(cur_frame(c,r)%color)
140140- cell_str(1:cl) = cur_frame(c,r)%color(1:cl)
141141- clen = cl
142142- end block
150150+ cl = len_trim(cur_frame(c,r)%color)
151151+ call fbuf_append(fbuf, fpos, cur_frame(c,r)%color(1:cl), cl)
152152+ else
153153+ call fbuf_append(fbuf, fpos, char(27)//'[0m', 4)
143154 end if
155155+ ! Bold
144156 if (cur_frame(c,r)%bold) then
145145- cell_str(clen+1:clen+4) = char(27)//'[1m'
146146- clen = clen + 4
157157+ call fbuf_append(fbuf, fpos, char(27)//'[1m', 4)
147158 end if
148148- cell_str(clen+1:clen+1) = cur_frame(c,r)%ch
149149- clen = clen + 1
150150- ! Append reset
151151- cell_str(clen+1:clen+4) = char(27)//'[0m'
152152- clen = clen + 4
153153-154154- ri = int(r, c_int)
155155- ci = int(c, c_int)
156156- call fortrat_write_at(ri, ci, cell_str, int(clen, c_int))
159159+ ! Character
160160+ call fbuf_append(fbuf, fpos, cur_frame(c,r)%ch, 1)
157161 prv_frame(c,r) = cur_frame(c,r)
162162+ if (fpos > fbuf_size - 64) exit
158163 end do
164164+ ! Reset at end of row
165165+ call fbuf_append(fbuf, fpos, char(27)//'[0m', 4)
166166+ if (fpos > fbuf_size - 64) exit
159167 end do
160168169169+ ! Single write call — atomic, no flicker
170170+ call fortrat_write_buf(fbuf, int(fpos, c_int))
161171 call fortrat_flush()
172172+ deallocate(fbuf)
162173 first_frame = .false.
163174 end subroutine
164175176176+ subroutine fbuf_append(buf, pos, str, n)
177177+ character(len=1), intent(inout) :: buf(:)
178178+ integer, intent(inout) :: pos
179179+ character(len=*), intent(in) :: str
180180+ integer, intent(in) :: n
181181+ integer :: i
182182+ do i = 1, n
183183+ pos = pos + 1
184184+ if (pos <= size(buf)) buf(pos) = str(i:i)
185185+ end do
186186+ end subroutine
187187+165188 ! ── Main graph pane render ──
166189 subroutine render_graph_pane(graph, state, w, h, row_off)
167190 type(lex_graph_t), intent(in) :: graph
168191 type(app_state_t), intent(in) :: state
169192 integer, intent(in) :: w, h, row_off
170193 integer :: i, cx, cy, vis_idx
171171- character(len=3) :: sigil
172194 character(len=16) :: col
173195 logical :: selected, dimmed
174196 integer :: visible_nodes(MAX_NODES), n_vis
175175-176197 ! Build visible node index list
177198 n_vis = 0
178199 do i = 1, graph%n_nodes
···203224 vis_idx = vis_idx + 1
204225 cx = nint(graph%nodes(i)%x)
205226 cy = nint(graph%nodes(i)%y) + row_off
206206- sigil = KIND_SIGIL(graph%nodes(i)%kind)(1:3)
207227 col = ns_color(graph%nodes(i)%ns_group)
208228 selected = (vis_idx == state%cursor_idx .or. i == state%selected_idx)
209229 dimmed = len_trim(state%search_query) > 0 .and. &
210230 index(graph%nodes(i)%id, trim(state%search_query)) == 0
211231 if (dimmed) col = GREEN_DIM
212212- call render_node(cx, cy, sigil, col, selected, w, h)
232232+ call render_node(cx, cy, trim(graph%nodes(i)%label), col, selected, w, h)
213233 end do
214234 end subroutine
215235···218238 type(lex_graph_t), intent(in) :: graph
219239 type(app_state_t), intent(in) :: state
220240 integer, intent(in) :: col_off, w, h
221221- integer :: row, i, idx, vis_idx, n_out
241241+ integer :: row, i, idx, vis_idx, n_out, pane_w
222242 character(len=ID_LEN) :: out_ids(64)
223243 character(len=3) :: sigil
224224- character(len=w) :: divider
225244226226- row = 2 ! start below pane header
245245+ pane_w = w - col_off
246246+ row = 2
227247228228- ! Find actual node index from cursor
229248 idx = 0
230249 vis_idx = 0
231250 do i = 1, graph%n_nodes
···233252 vis_idx = vis_idx + 1
234253 if (vis_idx == state%cursor_idx) then; idx = i; exit; end if
235254 end do
236236-237255 if (state%selected_idx > 0) idx = state%selected_idx
238256239257 if (idx == 0) then
···250268 sigil = KIND_SIGIL(graph%nodes(idx)%kind)(1:3)
251269 call render_text(col_off, row, 'SUBROUTINE INSPECT('//sigil//')', GREEN_BR, .true., w, h)
252270 row = row + 1
253253-254254- divider = repeat('-', w - col_off)
255255- call render_text(col_off, row, divider, GREEN_DIM, .false., w, h)
271271+ call render_text(col_off, row, repeat('-', pane_w), GREEN_DIM, .false., w, h)
256272 row = row + 1
257257-258273 call render_text(col_off, row, trim(graph%nodes(idx)%id), GREEN_BR, .true., w, h)
259274 row = row + 2
260275···270285 end if
271286 row = row + 1
272287273273- ! Description
274274- if (len_trim(graph%nodes(idx)%doc) > 0) then
288288+ ! Description with word-wrap
289289+ if (len_trim(graph%nodes(idx)%doc) > 0 .and. row < h - 3) then
275290 block
276276- integer :: doc_len, max_len
277277- doc_len = len_trim(graph%nodes(idx)%doc)
278278- max_len = min(doc_len, w - col_off - 6)
279279- call render_text(col_off, row, &
280280- 'C '//graph%nodes(idx)%doc(1:max_len), &
281281- GREEN_DIM, .false., w, h)
291291+ integer :: dlen, lw, pos, npos
292292+ character(len=DOC_LEN) :: doc
293293+ doc = trim(graph%nodes(idx)%doc)
294294+ dlen = len_trim(doc)
295295+ lw = pane_w - 6
296296+ pos = 1
297297+ call render_text(col_off, row, 'C DESCRIPTION', GREEN_DIM, .false., w, h)
298298+ row = row + 1
299299+ do while (pos <= dlen .and. row < h - 3)
300300+ npos = min(pos + lw - 1, dlen)
301301+ call render_text(col_off, row, 'C '//doc(pos:npos), GREEN_DIM, .false., w, h)
302302+ pos = npos + 1
303303+ row = row + 1
304304+ end do
305305+ row = row + 1
282306 end block
283283- row = row + 2
284307 end if
285308286309 ! Fields
287287- if (graph%nodes(idx)%n_fields > 0) then
310310+ if (graph%nodes(idx)%n_fields > 0 .and. row < h - 3) then
288311 call render_text(col_off, row, 'C FIELDS', GREEN_DIM, .false., w, h)
289312 row = row + 1
290313 do i = 1, min(graph%nodes(idx)%n_fields, 8)
···319342 end if
320343 end if
321344322322- ! Footer
323345 call render_text(col_off, h-1, 'END SUBROUTINE INSPECT', GREEN_DIM, .false., w, h)
324346 end subroutine
325347
+4-3
src/simulate.f90
···1212 real(real64), parameter :: ALPHA_DECAY = 0.02d0
1313 real(real64), parameter :: VELOCITY_DEC = 0.6d0 ! velocity decay per tick
14141515- real(real64), save :: alpha = 1.0d0
1515+ real(real64), save, public :: alpha = 1.0d0
16161717contains
1818···134134135135 ! ── Cool down ──
136136 alpha = alpha * (1.0d0 - ALPHA_DECAY)
137137- if (alpha < 0.001d0) alpha = 0.001d0
137137+ if (alpha < 0.0005d0) alpha = 0.0d0 ! fully settled
138138 end subroutine
139139140140 ! Pre-warm: run N ticks with high alpha to settle initial layout
···146146 do i = 1, n_ticks
147147 call sim_tick(graph, w, h)
148148 end do
149149- alpha = 0.3d0 ! keep some heat for live animation
149149+ ! Leave a little heat so it finishes settling live, but not too much
150150+ if (alpha > 0.05d0) alpha = 0.05d0
150151 end subroutine
151152152153end module fortrat_simulate
+6
src/tui.f90
···2222 integer(c_int), intent(out) :: cols, rows
2323 end subroutine
24242525+ subroutine fortrat_write_buf(buf, len) bind(c, name='fortrat_write_buf')
2626+ import c_int, c_char
2727+ integer(c_int), value :: len
2828+ character(kind=c_char), intent(in):: buf(len)
2929+ end subroutine
3030+2531 subroutine fortrat_clear_screen() bind(c, name='fortrat_clear_screen')
2632 end subroutine
2733
+5
src/tui_helper.c
···5656 xwrite(STDOUT_FILENO, str, len);
5757}
58585959+/* Write an entire pre-built frame buffer in one shot */
6060+void fortrat_write_buf(const char *buf, int len) {
6161+ xwrite(STDOUT_FILENO, buf, len);
6262+}
6363+5964void fortrat_flush(void) {
6065 fsync(STDOUT_FILENO);
6166}