Select the types of activity you want to include in your feed.
A terminal-only Bluesky / AT Protocol client written in Fortran, with a asm/Rust native firehose decoder for the relay-raw stream. DM slide support. Dither image composer. Yes, that Fortran
www.patreon.com/FormerLab
···33Yes, that Fortran.
4455A terminal-only Bluesky / AT Protocol client written in Fortran. Posts, timelines,
66-notifications, dithered images, and now DMs — all from an amber terminal, all the
77-way down to the protocol.
66+notifications, dithered image posting, DMs, and terminal image rendering — all from
77+an amber terminal (if you run it in cool-retro-term), all the way down to the protocol
8899Project blog: https://www.patreon.com/posts/153457794
1010-1111-This is version 1.4, DM sliding...next up is DM E2EE Germ-style and feed composer (true Fortran territory)
1212-Yes, we got a bit of .py and .h and .c, but phasing them out as we go. Not entirely possible
13101411---
1512···3431 pixels_to_png.py pixel file → PNG
3532 uploadBlob PNG → AT Protocol blob
3633 createRecord post with app.bsky.embed.images
3434+3535+image view path (v on a post with image):
3636+ CDN blob fetch download fullsize image via http_get_to_file
3737+ dither_prep.py greyscale + resize to 80×48 for terminal
3838+ dither.f90 Floyd-Steinberg in Fortran
3939+ render Unicode half-block chars (▀ ▄ █) — 80×48 effective pixels
37403841DM path (dm <handle>, i):
3942 getConvoForMembers resolve or create conversation
···72757376### Python deps
74777575-Required for relay-raw stream path and image posting:
7878+Required for relay-raw stream path, image posting, and image viewing:
76797780```bash
7881sudo pip install cbor2 websockets Pillow --break-system-packages
···8588pip install cbor2 websockets Pillow
8689```
87908888-### Assemblersky (optional, relay-raw native decoder)
9191+### Assemblersky integrated in Fortransky (relay-raw native decoder)
89929093Assemblersky decodes raw AT Protocol firehose frames in x86-64 assembly.
9194If present at `bridge/assemblersky/bin/assemblersky_cli`, it is preferred
9295automatically.
93969494-9595-## We got a non-public repo for Assemblersky, later when we go public with the Assembler module this is the guide:
9797+Assemblersky module repo is not public yet, but here is how to do it later when shipped separately:
96989797-Build from source: https://github.com/FormerLab/assemblersky (again, nota public repo right right now)
9999+Build from source: https://github.com/FormerLab/assemblersky
9810099101```bash
100102cd /path/to/assemblersky && make
···107109108110---
109111110110-## Build and run Fortransky
112112+## Build
113113+114114+chmod and
111115112116```bash
113117./scripts/build.sh
···134138135139---
136140137137-## TUI commands
141141+## TUI commands (only TUI here)
138142139143### Home view
140144···161165|---------|--------|
162166| `j` / `k` | move selection |
163167| `n` / `p` | next / previous page |
164164-| `o` | open selected thread |
168168+| `o` | open thread |
169169+| `v` | view image (dithered, half-block render) |
165170| `r` | reply to selected post |
166171| `l` | like selected post |
167172| `R` | repost selected post |
···203208204209---
205210211211+## Image viewing
212212+213213+The `v` command on any post with an image fetches the image from the Bluesky
214214+CDN, dithers it in Fortran, and renders it inline using Unicode half-block
215215+characters (`▀` `▄` `█`).
216216+217217+Each terminal character represents two pixel rows — top half and bottom half —
218218+giving 80×48 effective pixels in a standard 80-column terminal. The same
219219+Floyd-Steinberg algorithm used for posting is used for display.
220220+221221+---
222222+206223## DMs
207224208225Fortransky implements `chat.bsky.convo.*` — the same DM protocol used by the
···220237## Stream modes
221238222239**jetstream** — Bluesky's Jetstream WebSocket service. Lower bandwidth, JSON
223223-native, easiest to work with.
240240+native, easiest to work with...
224241225242**relay-raw** — raw AT Protocol relay (`com.atproto.sync.subscribeRepos`).
226243Binary CBOR frames over WebSocket, decoded in Python with cbor2. The native
···252269---
253270254271## Changelog
272272+273273+**v1.5** — Terminal image viewer via `v` command. Fetches image from Bluesky
274274+CDN, dithers with Floyd-Steinberg in Fortran, renders using Unicode half-block
275275+characters (80×48 effective pixels). Deep JSON key extraction added to
276276+`json_extract_mod` for nested embed fields.
255277256278**v1.4** — DM support via `chat.bsky.convo.*`. `dm <handle>` opens or creates
257279a conversation, `i` lists the inbox, `r` sends a reply. PDS host auto-resolved
+44
cshim/http_bridge.c
···166166void fortransky_http_free(char *ptr) {
167167 free(ptr);
168168}
169169+170170+/* Download binary content directly to a file.
171171+ Used for com.atproto.sync.getBlob image downloads.
172172+ Returns 0 on success, non-zero on failure. */
173173+int fortransky_http_get_to_file(const char *url, const char *auth_header,
174174+ const char *out_path,
175175+ long *status_code) {
176176+ CURL *curl;
177177+ CURLcode res;
178178+ struct curl_slist *headers = NULL;
179179+ FILE *fp;
180180+ int result = 1;
181181+182182+ if (status_code) *status_code = 0;
183183+184184+ fp = fopen(out_path, "wb");
185185+ if (!fp) return 1;
186186+187187+ curl_global_init(CURL_GLOBAL_DEFAULT);
188188+ curl = curl_easy_init();
189189+ if (!curl) { fclose(fp); return 1; }
190190+191191+ curl_easy_setopt(curl, CURLOPT_URL, url);
192192+ curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
193193+ curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
194194+ curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/1.4");
195195+ curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NULL); /* default: fwrite */
196196+ curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp);
197197+198198+ if (auth_header && auth_header[0] != '\0')
199199+ headers = curl_slist_append(headers, auth_header);
200200+ if (headers) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
201201+202202+ res = curl_easy_perform(curl);
203203+ if (res == CURLE_OK) {
204204+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status_code);
205205+ result = (*status_code == 200) ? 0 : 1;
206206+ }
207207+208208+ fclose(fp);
209209+ if (headers) curl_slist_free_all(headers);
210210+ curl_easy_cleanup(curl);
211211+ return result;
212212+}
+18-1
src/atproto/client.f90
···11module client_mod
22 use http_cbridge_mod, only: http_get, http_post_json, http_post_binary, http_get_urlencoded, &
33- http_get_proxied, http_post_json_proxied, last_http_status
33+ http_get_proxied, http_post_json_proxied, http_get_to_file, last_http_status
44 use json_extract_mod, only: extract_json_string, escape_json_string
55 use decode_mod, only: decode_posts_json, decode_stream_blob, decode_thread_json, decode_profile_json, decode_notifications_json
66 use models_mod, only: session_state, post_view, stream_event, actor_profile, notification_view, &
···1515 public :: fetch_profile_view, fetch_notifications_view, load_saved_session, save_session, clear_saved_session
1616 public :: resolve_did_to_handle, upload_blob, create_image_post
1717 public :: list_convos, get_messages, send_dm, get_convo_for_member
1818+ public :: fetch_image_blob
1819contains
1920 subroutine load_saved_session(state)
2021 type(session_state), intent(inout) :: state
···904905 message = 'sendMessage failed. Response: ' // body(1:min(len(body),120))
905906 end if
906907 end subroutine send_dm
908908+909909+ ! ----------------------------------------------------------------
910910+ ! fetch_image_blob — download an image URL to a local file
911911+ ! ----------------------------------------------------------------
912912+ subroutine fetch_image_blob(image_url, out_path, ok, message)
913913+ character(len=*), intent(in) :: image_url, out_path
914914+ logical, intent(out) :: ok
915915+ character(len=*), intent(out) :: message
916916+917917+ ok = http_get_to_file(trim(image_url), trim(out_path))
918918+ if (ok) then
919919+ message = 'Image downloaded'
920920+ else
921921+ message = 'Could not fetch image (HTTP ' // trim(itoa(last_http_status)) // ')'
922922+ end if
923923+ end subroutine fetch_image_blob
907924908925end module client_mod
+7-2
src/atproto/decode.f90
···11module decode_mod
22 use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, FIELD_LEN, HANDLE_LEN, URI_LEN, CID_LEN, TS_LEN
33 use json_extract_mod, only: extract_json_string, extract_json_object_after, extract_json_array_after, &
44- next_array_object, extract_reply_refs, slice_fit, find_first_array
44+ next_array_object, extract_reply_refs, slice_fit, find_first_array, &
55+ extract_json_string_any
56 implicit none
67 private
78 public :: decode_posts_json, decode_thread_json, decode_profile_json, decode_notifications_json, decode_stream_blob
···203204 call extract_reply_refs(record_obj, post%parent_uri, post%parent_cid, post%root_uri, post%root_cid)
204205 if (index(record_obj, '"facets"') > 0) post%has_facets = .true.
205206 end if
206206- if (index(post_obj, 'app.bsky.embed.images') > 0) post%has_images = .true.
207207+ if (index(post_obj, 'app.bsky.embed.images') > 0) then
208208+ post%has_images = .true.
209209+ ! fullsize URL is nested deep in embed.images[] — use any-depth search
210210+ post%image_url = slice_fit(extract_json_string_any(post_obj, 'fullsize'), URI_LEN)
211211+ end if
207212 if (index(post_obj, 'app.bsky.embed.video') > 0) post%has_video = .true.
208213 if (index(post_obj, 'app.bsky.embed.external') > 0) post%has_external = .true.
209214 if (index(post_obj, 'app.bsky.embed.record') > 0) then
+29-2
src/atproto/http_cbridge.f90
···11module http_cbridge_mod
22- use iso_c_binding, only: c_ptr, c_char, c_long, c_size_t, c_null_char, c_associated, c_f_pointer, c_int8_t
22+ use iso_c_binding, only: c_ptr, c_char, c_long, c_size_t, c_null_char, c_associated, c_f_pointer, c_int8_t, c_int
33 use strings_mod, only: url_encode
44 implicit none
55 private
66 public :: http_get, http_post_json, http_post_binary, http_get_urlencoded, &
77- http_get_proxied, http_post_json_proxied, last_http_status
77+ http_get_proxied, http_post_json_proxied, http_get_to_file, last_http_status
8899 integer :: last_http_status = 0
1010···6868 integer(c_size_t), intent(out) :: out_len
6969 type(c_ptr) :: res
7070 end function fortransky_http_post_json_proxied
7171+7272+ function fortransky_http_get_to_file(url, auth_header, out_path, status_code) &
7373+ bind(C, name='fortransky_http_get_to_file') result(rc)
7474+ import :: c_char, c_long, c_int
7575+ character(kind=c_char), dimension(*), intent(in) :: url
7676+ character(kind=c_char), dimension(*), intent(in) :: auth_header
7777+ character(kind=c_char), dimension(*), intent(in) :: out_path
7878+ integer(c_long), intent(out) :: status_code
7979+ integer(c_int) :: rc
8080+ end function fortransky_http_get_to_file
7181 end interface
7282contains
7383 function http_get(url, auth_token) result(body)
···179189 body = from_c_buffer(raw, out_len)
180190 if (c_associated(raw)) call fortransky_http_free(raw)
181191 end function http_post_json_proxied
192192+193193+ ! Download binary content to a file — for blob/image fetch
194194+ function http_get_to_file(url, out_path, auth_token) result(ok)
195195+ character(len=*), intent(in) :: url, out_path
196196+ character(len=*), intent(in), optional :: auth_token
197197+ logical :: ok
198198+ character(len=:), allocatable :: header
199199+ integer(c_long) :: status_code
200200+ integer(c_int) :: rc
201201+202202+ header = auth_header_value(auth_token)
203203+ rc = fortransky_http_get_to_file( &
204204+ c_string(trim(url)), c_string(header), &
205205+ c_string(trim(out_path)), status_code)
206206+ last_http_status = int(status_code)
207207+ ok = (rc == 0)
208208+ end function http_get_to_file
182209183210 function auth_header_value(auth_token) result(header)
184211 character(len=*), intent(in), optional :: auth_token
+28
src/atproto/json_extract.f90
···77 public :: next_array_object, extract_reply_refs, slice_fit, find_first_array
88 public :: extract_posts, extract_thread_posts, extract_stream_events
99 public :: escape_json_string, extract_profile, extract_notifications
1010+ public :: extract_json_string_any
1011contains
1112 function extract_json_string(json, key, start_at) result(value)
1213 character(len=*), intent(in) :: json, key
···2526 value = ''
2627 end if
2728 end function extract_json_string
2929+3030+ ! Like extract_json_string but searches at any nesting depth.
3131+ ! Use for keys that appear deep in nested objects (e.g. embed.images[0].fullsize).
3232+ function extract_json_string_any(json, key) result(value)
3333+ character(len=*), intent(in) :: json, key
3434+ character(len=:), allocatable :: value
3535+ character(len=:), allocatable :: key_pat
3636+ integer :: pos, vstart, vend
3737+3838+ value = ''
3939+ key_pat = '"' // trim(key) // '"'
4040+ pos = index(json, key_pat)
4141+ if (pos == 0) return
4242+4343+ ! Skip past the key and colon to the value
4444+ pos = pos + len(key_pat)
4545+ do while (pos <= len(json) .and. (json(pos:pos) == ' ' .or. json(pos:pos) == ':'))
4646+ pos = pos + 1
4747+ end do
4848+ if (pos > len(json)) return
4949+5050+ if (json(pos:pos) == '"') then
5151+ vstart = pos + 1
5252+ vend = parse_json_string_end(json, pos)
5353+ if (vend > pos) value = squeeze_spaces(json_unescape(json(vstart:vend-1)))
5454+ end if
5555+ end function extract_json_string_any
28562957 function extract_json_object_after(json, key, start_at) result(obj)
3058 character(len=*), intent(in) :: json, key
+1
src/core/models.f90
···3030 logical :: has_video = .false.
3131 logical :: has_external = .false.
3232 logical :: has_facets = .false.
3333+ character(len=URI_LEN) :: image_url = '' ! first image fullsize URL for terminal render
3334 end type post_view
34353536 type :: stream_event
+70-6
src/ui/tui.f90
···33 fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, &
44 fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, &
55 resolve_did_to_handle, create_image_post, &
66- list_convos, get_messages, send_dm, get_convo_for_member
77- use dither_mod, only: run_dither
66+ list_convos, get_messages, send_dm, get_convo_for_member, &
77+ fetch_image_blob
88+ use dither_mod, only: run_dither, run_dither_for_display, render_pixels_terminal
89 use models_mod, only: post_view, stream_event, actor_profile, notification_view, convo_view, dm_message, MAX_ITEMS
910 use config_mod, only: load_session_from_env
1011 use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, &
···58595960 subroutine draw_header(state)
6061 type(app_state), intent(in) :: state
6161- write(*,'(a)') 'Fortransky v1.4 - TUI only'
6262+ write(*,'(a)') 'Fortransky v1.5 - TUI only'
6263 write(*,'(a)') repeat('=', 28)
6364 write(*,'(a)') 'View : ' // trim(state%view_title)
6465 if (len_trim(state%session%identifier) > 0) write(*,'(a)') 'User : ' // trim(state%session%identifier)
···129130 if (len_trim(post%uri) > 0) call wrap_print('URI : ', trim(post%uri), 96)
130131 write(*,'(a)') repeat('-', 72)
131132 end do
132132- write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, P profile, b back, / search'
133133+ write(*,'(a)') 'Commands: j/k move, n/p page, o open, v view image, r reply, P profile, b back, / search'
133134 end subroutine draw_post_list
134135135136 subroutine draw_profile(state)
···178179 if (len_trim(state%notifications(i)%uri) > 0) call wrap_print('URI : ', trim(state%notifications(i)%uri), 96)
179180 write(*,'(a)') repeat('-', 72)
180181 end do
181181- write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, l like, R repost, q quote, b back'
182182+ write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, v view image, r reply, l like, R repost, q quote, b back'
182183 end subroutine draw_notifications
183184184185 subroutine draw_stream(events, n, message)
···187188 character(len=*), intent(in) :: message
188189 integer :: i
189190 call clear_screen()
190190- write(*,'(a)') 'Fortransky v1.4 - stream tail'
191191+ write(*,'(a)') 'Fortransky v1.5 - stream tail'
191192 write(*,'(a)') repeat('=', 28)
192193 write(*,'(a)') trim(message)
193194 write(*,'(a)') ''
···870871 else
871872 call set_status(state, 'No selected post.')
872873 end if
874874+ case ('v')
875875+ call get_current_post(state, state%selected, target, ok)
876876+ if (ok .and. target%has_images .and. len_trim(target%image_url) > 0) then
877877+ call show_dithered_image(state, target%image_url)
878878+ else if (ok .and. .not. target%has_images) then
879879+ call set_status(state, 'Selected post has no image.')
880880+ else
881881+ call set_status(state, 'No selected post or missing image URL.')
882882+ end if
873883 case ('r')
874884 call reply_to_selected_post(state)
875885 case ('l')
···10201030 end select
10211031 end do
10221032 end subroutine app_loop
10331033+ ! ----------------------------------------------------------------
10341034+ ! show_dithered_image — fetch a blob, dither it, render in terminal
10351035+ ! ----------------------------------------------------------------
10361036+ subroutine show_dithered_image(state, image_url)
10371037+ type(app_state), intent(inout) :: state
10381038+ character(len=*), intent(in) :: image_url
10391039+10401040+ logical :: ok
10411041+ character(len=256) :: message, line
10421042+ integer :: ios
10431043+10441044+ call set_status(state, 'Fetching image...')
10451045+10461046+ ! Step 1 — download image to temp file
10471047+ call fetch_image_blob(trim(image_url), '/tmp/fortransky_blob.jpg', ok, message)
10481048+ if (.not. ok) then
10491049+ call set_status(state, 'Image fetch failed: ' // trim(message))
10501050+ return
10511051+ end if
10521052+10531053+ ! Step 2 — prep: greyscale + resize to 80×48 for terminal display
10541054+ call set_status(state, 'Dithering image...')
10551055+ call execute_command_line( &
10561056+ 'python3 scripts/dither_prep.py /tmp/fortransky_blob.jpg' // &
10571057+ ' --width 80 --height 48' // &
10581058+ ' --out /tmp/fortransky_display_in.dat 2>/dev/null', &
10591059+ wait=.true., exitstat=ios)
10601060+ if (ios /= 0) then
10611061+ call set_status(state, 'dither_prep.py failed — is Pillow installed?')
10621062+ return
10631063+ end if
10641064+10651065+ ! Step 3 — dither in Fortran
10661066+ call run_dither_for_display('/tmp/fortransky_display_in.dat', &
10671067+ '/tmp/fortransky_display_out.dat', ok, message)
10681068+ if (.not. ok) then
10691069+ call set_status(state, 'Dither failed: ' // trim(message))
10701070+ return
10711071+ end if
10721072+10731073+ ! Step 4 — render to terminal using half-block chars
10741074+ call clear_screen()
10751075+ call render_pixels_terminal('/tmp/fortransky_display_out.dat', ok, message)
10761076+ if (.not. ok) then
10771077+ call set_status(state, 'Render failed: ' // trim(message))
10781078+ return
10791079+ end if
10801080+10811081+ write(*,*)
10821082+ write(*,'(a)', advance='no') 'Press Enter to continue...'
10831083+ read(*,'(a)') line
10841084+10851085+ end subroutine show_dithered_image
10861086+10231087end module tui_mod
+143-1
src/util/dither.f90
···1717module dither_mod
1818 implicit none
1919 private
2020- public :: run_dither
2020+ public :: run_dither, run_dither_for_display, render_pixels_terminal
21212222 integer, parameter :: MAX_COLS = 576
2323 character(len=*), parameter :: PIXELS_IN = '/tmp/bsky_pixels_in.dat'
···227227 write(unit, '(a)') line(1:pos-1)
228228229229 end subroutine write_row
230230+231231+ ! ----------------------------------------------------------------
232232+ ! run_dither_for_display — like run_dither but uses custom pixel files
233233+ ! for terminal display (smaller canvas than the posting path)
234234+ ! ----------------------------------------------------------------
235235+ subroutine run_dither_for_display(pixels_in, pixels_out, ok, message)
236236+ character(len=*), intent(in) :: pixels_in, pixels_out
237237+ logical, intent(out) :: ok
238238+ character(len=*), intent(out) :: message
239239+240240+ integer :: width, height
241241+ integer :: row, col
242242+ integer :: old_val, new_val, err
243243+ integer :: err7, err3, err5, err1
244244+ integer :: curr(MAX_COLS), nxt(MAX_COLS), out_row(MAX_COLS)
245245+ character(len=4000) :: line_buf
246246+ integer :: in_unit, out_unit, ios
247247+248248+ ok = .false.
249249+ message = 'Dither failed'
250250+251251+ open(newunit=in_unit, file=trim(pixels_in), status='old', action='read', iostat=ios)
252252+ if (ios /= 0) then; message = 'Cannot open ' // trim(pixels_in); return; end if
253253+ open(newunit=out_unit, file=trim(pixels_out), status='replace', action='write', iostat=ios)
254254+ if (ios /= 0) then; close(in_unit); message = 'Cannot open ' // trim(pixels_out); return; end if
255255+256256+ read(in_unit, '(a)', iostat=ios) line_buf
257257+ if (ios /= 0) then; close(in_unit); close(out_unit); message = 'Bad header'; return; end if
258258+ read(line_buf(1:5), '(i5)') width
259259+ read(line_buf(6:10), '(i5)') height
260260+261261+ if (width < 1 .or. width > MAX_COLS .or. height < 1) then
262262+ message = 'Invalid dimensions'; close(in_unit); close(out_unit); return
263263+ end if
264264+265265+ write(out_unit, '(a)') trim(line_buf)
266266+ curr = 0; nxt = 0; out_row = 0
267267+268268+ call read_row(in_unit, width, nxt, ios)
269269+ if (ios /= 0) then
270270+ close(in_unit); close(out_unit); message = 'Bad first row'; return
271271+ end if
272272+273273+ do row = 1, height
274274+ curr(1:width) = nxt(1:width)
275275+ nxt(1:width) = 0
276276+ if (row < height) then
277277+ call read_row(in_unit, width, nxt, ios)
278278+ if (ios /= 0) nxt(1:width) = 0
279279+ end if
280280+ do col = 1, width
281281+ old_val = curr(col)
282282+ if (old_val < 0) old_val = 0
283283+ if (old_val > 255) old_val = 255
284284+ if (old_val >= 128) then; new_val = 255; else; new_val = 0; end if
285285+ out_row(col) = new_val
286286+ err = old_val - new_val
287287+ if (col < width) curr(col+1) = curr(col+1) + err * 7 / 16
288288+ if (col > 1) nxt(col-1) = nxt(col-1) + err * 3 / 16
289289+ nxt(col) = nxt(col) + err * 5 / 16
290290+ if (col < width) nxt(col+1) = nxt(col+1) + err * 1 / 16
291291+ end do
292292+ call write_row(out_unit, width, out_row)
293293+ end do
294294+295295+ close(in_unit); close(out_unit)
296296+ ok = .true.; message = 'Dither complete'
297297+ end subroutine run_dither_for_display
298298+299299+ ! ----------------------------------------------------------------
300300+ ! render_pixels_terminal — read dithered pixel file and print using
301301+ ! Unicode half-block characters for double vertical resolution.
302302+ !
303303+ ! Half-block encoding (two pixel rows per terminal line):
304304+ ! top=0, bot=0 → ' ' (space)
305305+ ! top=1, bot=0 → '▀' (upper half block)
306306+ ! top=0, bot=1 → '▄' (lower half block)
307307+ ! top=1, bot=1 → '█' (full block)
308308+ ! where 0=black (dithered on), 1=white (dithered off)
309309+ ! ----------------------------------------------------------------
310310+ subroutine render_pixels_terminal(pixels_out, ok, message)
311311+ character(len=*), intent(in) :: pixels_out
312312+ logical, intent(out) :: ok
313313+ character(len=*), intent(out) :: message
314314+315315+ integer :: width, height, row, col
316316+ integer :: top_px, bot_px
317317+ integer :: top_row(MAX_COLS), bot_row(MAX_COLS)
318318+ character(len=4000) :: line_buf
319319+ integer :: in_unit, ios
320320+ character(len=:), allocatable :: out_line
321321+322322+ ok = .false.; message = 'render failed'
323323+324324+ open(newunit=in_unit, file=trim(pixels_out), status='old', action='read', iostat=ios)
325325+ if (ios /= 0) then; message = 'Cannot open ' // trim(pixels_out); return; end if
326326+327327+ read(in_unit, '(a)', iostat=ios) line_buf
328328+ if (ios /= 0) then; close(in_unit); return; end if
329329+ read(line_buf(1:5), '(i5)') width
330330+ read(line_buf(6:10), '(i5)') height
331331+332332+ ! Process two pixel rows at a time → one terminal line
333333+ row = 0
334334+ do while (row < height)
335335+ ! Read top pixel row
336336+ call read_row(in_unit, width, top_row, ios)
337337+ if (ios /= 0) exit
338338+ row = row + 1
339339+340340+ ! Read bottom pixel row (or use white if at last row)
341341+ if (row < height) then
342342+ call read_row(in_unit, width, bot_row, ios)
343343+ if (ios /= 0) bot_row(1:width) = 255
344344+ row = row + 1
345345+ else
346346+ bot_row(1:width) = 255
347347+ end if
348348+349349+ ! Build output line using half-block chars
350350+ out_line = ''
351351+ do col = 1, width
352352+ top_px = top_row(col) ! 0=black, 255=white
353353+ bot_px = bot_row(col)
354354+355355+ if (top_px < 128 .and. bot_px < 128) then
356356+ out_line = out_line // char(226) // char(150) // char(136) ! █ U+2588
357357+ else if (top_px < 128 .and. bot_px >= 128) then
358358+ out_line = out_line // char(226) // char(150) // char(128) ! ▀ U+2580
359359+ else if (top_px >= 128 .and. bot_px < 128) then
360360+ out_line = out_line // char(226) // char(150) // char(132) ! ▄ U+2584
361361+ else
362362+ out_line = out_line // ' '
363363+ end if
364364+ end do
365365+366366+ write(*, '(a)') trim(out_line)
367367+ end do
368368+369369+ close(in_unit)
370370+ ok = .true.; message = 'Rendered'
371371+ end subroutine render_pixels_terminal
230372231373end module dither_mod