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
rust atproto fun fortran assembly
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

Fortransky v1.5 — TUI image viewer, half-block dither render, deep JSON extraction

FormerLab d11debe7 1c12b967

+376 -26
+36 -14
README.md
··· 3 3 Yes, that Fortran. 4 4 5 5 A terminal-only Bluesky / AT Protocol client written in Fortran. Posts, timelines, 6 - notifications, dithered images, and now DMs — all from an amber terminal, all the 7 - way down to the protocol. 6 + notifications, dithered image posting, DMs, and terminal image rendering — all from 7 + an amber terminal (if you run it in cool-retro-term), all the way down to the protocol 8 8 9 9 Project blog: https://www.patreon.com/posts/153457794 10 - 11 - This is version 1.4, DM sliding...next up is DM E2EE Germ-style and feed composer (true Fortran territory) 12 - Yes, we got a bit of .py and .h and .c, but phasing them out as we go. Not entirely possible 13 10 14 11 --- 15 12 ··· 34 31 pixels_to_png.py pixel file → PNG 35 32 uploadBlob PNG → AT Protocol blob 36 33 createRecord post with app.bsky.embed.images 34 + 35 + image view path (v on a post with image): 36 + CDN blob fetch download fullsize image via http_get_to_file 37 + dither_prep.py greyscale + resize to 80×48 for terminal 38 + dither.f90 Floyd-Steinberg in Fortran 39 + render Unicode half-block chars (▀ ▄ █) — 80×48 effective pixels 37 40 38 41 DM path (dm <handle>, i): 39 42 getConvoForMembers resolve or create conversation ··· 72 75 73 76 ### Python deps 74 77 75 - Required for relay-raw stream path and image posting: 78 + Required for relay-raw stream path, image posting, and image viewing: 76 79 77 80 ```bash 78 81 sudo pip install cbor2 websockets Pillow --break-system-packages ··· 85 88 pip install cbor2 websockets Pillow 86 89 ``` 87 90 88 - ### Assemblersky (optional, relay-raw native decoder) 91 + ### Assemblersky integrated in Fortransky (relay-raw native decoder) 89 92 90 93 Assemblersky decodes raw AT Protocol firehose frames in x86-64 assembly. 91 94 If present at `bridge/assemblersky/bin/assemblersky_cli`, it is preferred 92 95 automatically. 93 96 94 - 95 - ## We got a non-public repo for Assemblersky, later when we go public with the Assembler module this is the guide: 97 + Assemblersky module repo is not public yet, but here is how to do it later when shipped separately: 96 98 97 - Build from source: https://github.com/FormerLab/assemblersky (again, nota public repo right right now) 99 + Build from source: https://github.com/FormerLab/assemblersky 98 100 99 101 ```bash 100 102 cd /path/to/assemblersky && make ··· 107 109 108 110 --- 109 111 110 - ## Build and run Fortransky 112 + ## Build 113 + 114 + chmod and 111 115 112 116 ```bash 113 117 ./scripts/build.sh ··· 134 138 135 139 --- 136 140 137 - ## TUI commands 141 + ## TUI commands (only TUI here) 138 142 139 143 ### Home view 140 144 ··· 161 165 |---------|--------| 162 166 | `j` / `k` | move selection | 163 167 | `n` / `p` | next / previous page | 164 - | `o` | open selected thread | 168 + | `o` | open thread | 169 + | `v` | view image (dithered, half-block render) | 165 170 | `r` | reply to selected post | 166 171 | `l` | like selected post | 167 172 | `R` | repost selected post | ··· 203 208 204 209 --- 205 210 211 + ## Image viewing 212 + 213 + The `v` command on any post with an image fetches the image from the Bluesky 214 + CDN, dithers it in Fortran, and renders it inline using Unicode half-block 215 + characters (`▀` `▄` `█`). 216 + 217 + Each terminal character represents two pixel rows — top half and bottom half — 218 + giving 80×48 effective pixels in a standard 80-column terminal. The same 219 + Floyd-Steinberg algorithm used for posting is used for display. 220 + 221 + --- 222 + 206 223 ## DMs 207 224 208 225 Fortransky implements `chat.bsky.convo.*` — the same DM protocol used by the ··· 220 237 ## Stream modes 221 238 222 239 **jetstream** — Bluesky's Jetstream WebSocket service. Lower bandwidth, JSON 223 - native, easiest to work with. 240 + native, easiest to work with... 224 241 225 242 **relay-raw** — raw AT Protocol relay (`com.atproto.sync.subscribeRepos`). 226 243 Binary CBOR frames over WebSocket, decoded in Python with cbor2. The native ··· 252 269 --- 253 270 254 271 ## Changelog 272 + 273 + **v1.5** — Terminal image viewer via `v` command. Fetches image from Bluesky 274 + CDN, dithers with Floyd-Steinberg in Fortran, renders using Unicode half-block 275 + characters (80×48 effective pixels). Deep JSON key extraction added to 276 + `json_extract_mod` for nested embed fields. 255 277 256 278 **v1.4** — DM support via `chat.bsky.convo.*`. `dm <handle>` opens or creates 257 279 a conversation, `i` lists the inbox, `r` sends a reply. PDS host auto-resolved
+44
cshim/http_bridge.c
··· 166 166 void fortransky_http_free(char *ptr) { 167 167 free(ptr); 168 168 } 169 + 170 + /* Download binary content directly to a file. 171 + Used for com.atproto.sync.getBlob image downloads. 172 + Returns 0 on success, non-zero on failure. */ 173 + int fortransky_http_get_to_file(const char *url, const char *auth_header, 174 + const char *out_path, 175 + long *status_code) { 176 + CURL *curl; 177 + CURLcode res; 178 + struct curl_slist *headers = NULL; 179 + FILE *fp; 180 + int result = 1; 181 + 182 + if (status_code) *status_code = 0; 183 + 184 + fp = fopen(out_path, "wb"); 185 + if (!fp) return 1; 186 + 187 + curl_global_init(CURL_GLOBAL_DEFAULT); 188 + curl = curl_easy_init(); 189 + if (!curl) { fclose(fp); return 1; } 190 + 191 + curl_easy_setopt(curl, CURLOPT_URL, url); 192 + curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); 193 + curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); 194 + curl_easy_setopt(curl, CURLOPT_USERAGENT, "fortransky/1.4"); 195 + curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, NULL); /* default: fwrite */ 196 + curl_easy_setopt(curl, CURLOPT_WRITEDATA, fp); 197 + 198 + if (auth_header && auth_header[0] != '\0') 199 + headers = curl_slist_append(headers, auth_header); 200 + if (headers) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers); 201 + 202 + res = curl_easy_perform(curl); 203 + if (res == CURLE_OK) { 204 + curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, status_code); 205 + result = (*status_code == 200) ? 0 : 1; 206 + } 207 + 208 + fclose(fp); 209 + if (headers) curl_slist_free_all(headers); 210 + curl_easy_cleanup(curl); 211 + return result; 212 + }
+18 -1
src/atproto/client.f90
··· 1 1 module client_mod 2 2 use http_cbridge_mod, only: http_get, http_post_json, http_post_binary, http_get_urlencoded, & 3 - http_get_proxied, http_post_json_proxied, last_http_status 3 + http_get_proxied, http_post_json_proxied, http_get_to_file, last_http_status 4 4 use json_extract_mod, only: extract_json_string, escape_json_string 5 5 use decode_mod, only: decode_posts_json, decode_stream_blob, decode_thread_json, decode_profile_json, decode_notifications_json 6 6 use models_mod, only: session_state, post_view, stream_event, actor_profile, notification_view, & ··· 15 15 public :: fetch_profile_view, fetch_notifications_view, load_saved_session, save_session, clear_saved_session 16 16 public :: resolve_did_to_handle, upload_blob, create_image_post 17 17 public :: list_convos, get_messages, send_dm, get_convo_for_member 18 + public :: fetch_image_blob 18 19 contains 19 20 subroutine load_saved_session(state) 20 21 type(session_state), intent(inout) :: state ··· 904 905 message = 'sendMessage failed. Response: ' // body(1:min(len(body),120)) 905 906 end if 906 907 end subroutine send_dm 908 + 909 + ! ---------------------------------------------------------------- 910 + ! fetch_image_blob — download an image URL to a local file 911 + ! ---------------------------------------------------------------- 912 + subroutine fetch_image_blob(image_url, out_path, ok, message) 913 + character(len=*), intent(in) :: image_url, out_path 914 + logical, intent(out) :: ok 915 + character(len=*), intent(out) :: message 916 + 917 + ok = http_get_to_file(trim(image_url), trim(out_path)) 918 + if (ok) then 919 + message = 'Image downloaded' 920 + else 921 + message = 'Could not fetch image (HTTP ' // trim(itoa(last_http_status)) // ')' 922 + end if 923 + end subroutine fetch_image_blob 907 924 908 925 end module client_mod
+7 -2
src/atproto/decode.f90
··· 1 1 module decode_mod 2 2 use models_mod, only: post_view, stream_event, actor_profile, notification_view, MAX_ITEMS, FIELD_LEN, HANDLE_LEN, URI_LEN, CID_LEN, TS_LEN 3 3 use json_extract_mod, only: extract_json_string, extract_json_object_after, extract_json_array_after, & 4 - next_array_object, extract_reply_refs, slice_fit, find_first_array 4 + next_array_object, extract_reply_refs, slice_fit, find_first_array, & 5 + extract_json_string_any 5 6 implicit none 6 7 private 7 8 public :: decode_posts_json, decode_thread_json, decode_profile_json, decode_notifications_json, decode_stream_blob ··· 203 204 call extract_reply_refs(record_obj, post%parent_uri, post%parent_cid, post%root_uri, post%root_cid) 204 205 if (index(record_obj, '"facets"') > 0) post%has_facets = .true. 205 206 end if 206 - if (index(post_obj, 'app.bsky.embed.images') > 0) post%has_images = .true. 207 + if (index(post_obj, 'app.bsky.embed.images') > 0) then 208 + post%has_images = .true. 209 + ! fullsize URL is nested deep in embed.images[] — use any-depth search 210 + post%image_url = slice_fit(extract_json_string_any(post_obj, 'fullsize'), URI_LEN) 211 + end if 207 212 if (index(post_obj, 'app.bsky.embed.video') > 0) post%has_video = .true. 208 213 if (index(post_obj, 'app.bsky.embed.external') > 0) post%has_external = .true. 209 214 if (index(post_obj, 'app.bsky.embed.record') > 0) then
+29 -2
src/atproto/http_cbridge.f90
··· 1 1 module http_cbridge_mod 2 - 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 2 + 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 3 3 use strings_mod, only: url_encode 4 4 implicit none 5 5 private 6 6 public :: http_get, http_post_json, http_post_binary, http_get_urlencoded, & 7 - http_get_proxied, http_post_json_proxied, last_http_status 7 + http_get_proxied, http_post_json_proxied, http_get_to_file, last_http_status 8 8 9 9 integer :: last_http_status = 0 10 10 ··· 68 68 integer(c_size_t), intent(out) :: out_len 69 69 type(c_ptr) :: res 70 70 end function fortransky_http_post_json_proxied 71 + 72 + function fortransky_http_get_to_file(url, auth_header, out_path, status_code) & 73 + bind(C, name='fortransky_http_get_to_file') result(rc) 74 + import :: c_char, c_long, c_int 75 + character(kind=c_char), dimension(*), intent(in) :: url 76 + character(kind=c_char), dimension(*), intent(in) :: auth_header 77 + character(kind=c_char), dimension(*), intent(in) :: out_path 78 + integer(c_long), intent(out) :: status_code 79 + integer(c_int) :: rc 80 + end function fortransky_http_get_to_file 71 81 end interface 72 82 contains 73 83 function http_get(url, auth_token) result(body) ··· 179 189 body = from_c_buffer(raw, out_len) 180 190 if (c_associated(raw)) call fortransky_http_free(raw) 181 191 end function http_post_json_proxied 192 + 193 + ! Download binary content to a file — for blob/image fetch 194 + function http_get_to_file(url, out_path, auth_token) result(ok) 195 + character(len=*), intent(in) :: url, out_path 196 + character(len=*), intent(in), optional :: auth_token 197 + logical :: ok 198 + character(len=:), allocatable :: header 199 + integer(c_long) :: status_code 200 + integer(c_int) :: rc 201 + 202 + header = auth_header_value(auth_token) 203 + rc = fortransky_http_get_to_file( & 204 + c_string(trim(url)), c_string(header), & 205 + c_string(trim(out_path)), status_code) 206 + last_http_status = int(status_code) 207 + ok = (rc == 0) 208 + end function http_get_to_file 182 209 183 210 function auth_header_value(auth_token) result(header) 184 211 character(len=*), intent(in), optional :: auth_token
+28
src/atproto/json_extract.f90
··· 7 7 public :: next_array_object, extract_reply_refs, slice_fit, find_first_array 8 8 public :: extract_posts, extract_thread_posts, extract_stream_events 9 9 public :: escape_json_string, extract_profile, extract_notifications 10 + public :: extract_json_string_any 10 11 contains 11 12 function extract_json_string(json, key, start_at) result(value) 12 13 character(len=*), intent(in) :: json, key ··· 25 26 value = '' 26 27 end if 27 28 end function extract_json_string 29 + 30 + ! Like extract_json_string but searches at any nesting depth. 31 + ! Use for keys that appear deep in nested objects (e.g. embed.images[0].fullsize). 32 + function extract_json_string_any(json, key) result(value) 33 + character(len=*), intent(in) :: json, key 34 + character(len=:), allocatable :: value 35 + character(len=:), allocatable :: key_pat 36 + integer :: pos, vstart, vend 37 + 38 + value = '' 39 + key_pat = '"' // trim(key) // '"' 40 + pos = index(json, key_pat) 41 + if (pos == 0) return 42 + 43 + ! Skip past the key and colon to the value 44 + pos = pos + len(key_pat) 45 + do while (pos <= len(json) .and. (json(pos:pos) == ' ' .or. json(pos:pos) == ':')) 46 + pos = pos + 1 47 + end do 48 + if (pos > len(json)) return 49 + 50 + if (json(pos:pos) == '"') then 51 + vstart = pos + 1 52 + vend = parse_json_string_end(json, pos) 53 + if (vend > pos) value = squeeze_spaces(json_unescape(json(vstart:vend-1))) 54 + end if 55 + end function extract_json_string_any 28 56 29 57 function extract_json_object_after(json, key, start_at) result(obj) 30 58 character(len=*), intent(in) :: json, key
+1
src/core/models.f90
··· 30 30 logical :: has_video = .false. 31 31 logical :: has_external = .false. 32 32 logical :: has_facets = .false. 33 + character(len=URI_LEN) :: image_url = '' ! first image fullsize URL for terminal render 33 34 end type post_view 34 35 35 36 type :: stream_event
+70 -6
src/ui/tui.f90
··· 3 3 fetch_post_thread, create_post, create_reply, create_quote_post, like_post, repost_post, & 4 4 fetch_profile_view, fetch_notifications_view, load_saved_session, clear_saved_session, & 5 5 resolve_did_to_handle, create_image_post, & 6 - list_convos, get_messages, send_dm, get_convo_for_member 7 - use dither_mod, only: run_dither 6 + list_convos, get_messages, send_dm, get_convo_for_member, & 7 + fetch_image_blob 8 + use dither_mod, only: run_dither, run_dither_for_display, render_pixels_terminal 8 9 use models_mod, only: post_view, stream_event, actor_profile, notification_view, convo_view, dm_message, MAX_ITEMS 9 10 use config_mod, only: load_session_from_env 10 11 use app_state_mod, only: app_state, VIEW_HOME, VIEW_POST_LIST, VIEW_PROFILE, VIEW_NOTIFICATIONS, VIEW_STREAM, & ··· 58 59 59 60 subroutine draw_header(state) 60 61 type(app_state), intent(in) :: state 61 - write(*,'(a)') 'Fortransky v1.4 - TUI only' 62 + write(*,'(a)') 'Fortransky v1.5 - TUI only' 62 63 write(*,'(a)') repeat('=', 28) 63 64 write(*,'(a)') 'View : ' // trim(state%view_title) 64 65 if (len_trim(state%session%identifier) > 0) write(*,'(a)') 'User : ' // trim(state%session%identifier) ··· 129 130 if (len_trim(post%uri) > 0) call wrap_print('URI : ', trim(post%uri), 96) 130 131 write(*,'(a)') repeat('-', 72) 131 132 end do 132 - write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, P profile, b back, / search' 133 + write(*,'(a)') 'Commands: j/k move, n/p page, o open, v view image, r reply, P profile, b back, / search' 133 134 end subroutine draw_post_list 134 135 135 136 subroutine draw_profile(state) ··· 178 179 if (len_trim(state%notifications(i)%uri) > 0) call wrap_print('URI : ', trim(state%notifications(i)%uri), 96) 179 180 write(*,'(a)') repeat('-', 72) 180 181 end do 181 - write(*,'(a)') 'Commands: j/k move, n/p page, o open thread, r reply, l like, R repost, q quote, b back' 182 + 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' 182 183 end subroutine draw_notifications 183 184 184 185 subroutine draw_stream(events, n, message) ··· 187 188 character(len=*), intent(in) :: message 188 189 integer :: i 189 190 call clear_screen() 190 - write(*,'(a)') 'Fortransky v1.4 - stream tail' 191 + write(*,'(a)') 'Fortransky v1.5 - stream tail' 191 192 write(*,'(a)') repeat('=', 28) 192 193 write(*,'(a)') trim(message) 193 194 write(*,'(a)') '' ··· 870 871 else 871 872 call set_status(state, 'No selected post.') 872 873 end if 874 + case ('v') 875 + call get_current_post(state, state%selected, target, ok) 876 + if (ok .and. target%has_images .and. len_trim(target%image_url) > 0) then 877 + call show_dithered_image(state, target%image_url) 878 + else if (ok .and. .not. target%has_images) then 879 + call set_status(state, 'Selected post has no image.') 880 + else 881 + call set_status(state, 'No selected post or missing image URL.') 882 + end if 873 883 case ('r') 874 884 call reply_to_selected_post(state) 875 885 case ('l') ··· 1020 1030 end select 1021 1031 end do 1022 1032 end subroutine app_loop 1033 + ! ---------------------------------------------------------------- 1034 + ! show_dithered_image — fetch a blob, dither it, render in terminal 1035 + ! ---------------------------------------------------------------- 1036 + subroutine show_dithered_image(state, image_url) 1037 + type(app_state), intent(inout) :: state 1038 + character(len=*), intent(in) :: image_url 1039 + 1040 + logical :: ok 1041 + character(len=256) :: message, line 1042 + integer :: ios 1043 + 1044 + call set_status(state, 'Fetching image...') 1045 + 1046 + ! Step 1 — download image to temp file 1047 + call fetch_image_blob(trim(image_url), '/tmp/fortransky_blob.jpg', ok, message) 1048 + if (.not. ok) then 1049 + call set_status(state, 'Image fetch failed: ' // trim(message)) 1050 + return 1051 + end if 1052 + 1053 + ! Step 2 — prep: greyscale + resize to 80×48 for terminal display 1054 + call set_status(state, 'Dithering image...') 1055 + call execute_command_line( & 1056 + 'python3 scripts/dither_prep.py /tmp/fortransky_blob.jpg' // & 1057 + ' --width 80 --height 48' // & 1058 + ' --out /tmp/fortransky_display_in.dat 2>/dev/null', & 1059 + wait=.true., exitstat=ios) 1060 + if (ios /= 0) then 1061 + call set_status(state, 'dither_prep.py failed — is Pillow installed?') 1062 + return 1063 + end if 1064 + 1065 + ! Step 3 — dither in Fortran 1066 + call run_dither_for_display('/tmp/fortransky_display_in.dat', & 1067 + '/tmp/fortransky_display_out.dat', ok, message) 1068 + if (.not. ok) then 1069 + call set_status(state, 'Dither failed: ' // trim(message)) 1070 + return 1071 + end if 1072 + 1073 + ! Step 4 — render to terminal using half-block chars 1074 + call clear_screen() 1075 + call render_pixels_terminal('/tmp/fortransky_display_out.dat', ok, message) 1076 + if (.not. ok) then 1077 + call set_status(state, 'Render failed: ' // trim(message)) 1078 + return 1079 + end if 1080 + 1081 + write(*,*) 1082 + write(*,'(a)', advance='no') 'Press Enter to continue...' 1083 + read(*,'(a)') line 1084 + 1085 + end subroutine show_dithered_image 1086 + 1023 1087 end module tui_mod
+143 -1
src/util/dither.f90
··· 17 17 module dither_mod 18 18 implicit none 19 19 private 20 - public :: run_dither 20 + public :: run_dither, run_dither_for_display, render_pixels_terminal 21 21 22 22 integer, parameter :: MAX_COLS = 576 23 23 character(len=*), parameter :: PIXELS_IN = '/tmp/bsky_pixels_in.dat' ··· 227 227 write(unit, '(a)') line(1:pos-1) 228 228 229 229 end subroutine write_row 230 + 231 + ! ---------------------------------------------------------------- 232 + ! run_dither_for_display — like run_dither but uses custom pixel files 233 + ! for terminal display (smaller canvas than the posting path) 234 + ! ---------------------------------------------------------------- 235 + subroutine run_dither_for_display(pixels_in, pixels_out, ok, message) 236 + character(len=*), intent(in) :: pixels_in, pixels_out 237 + logical, intent(out) :: ok 238 + character(len=*), intent(out) :: message 239 + 240 + integer :: width, height 241 + integer :: row, col 242 + integer :: old_val, new_val, err 243 + integer :: err7, err3, err5, err1 244 + integer :: curr(MAX_COLS), nxt(MAX_COLS), out_row(MAX_COLS) 245 + character(len=4000) :: line_buf 246 + integer :: in_unit, out_unit, ios 247 + 248 + ok = .false. 249 + message = 'Dither failed' 250 + 251 + open(newunit=in_unit, file=trim(pixels_in), status='old', action='read', iostat=ios) 252 + if (ios /= 0) then; message = 'Cannot open ' // trim(pixels_in); return; end if 253 + open(newunit=out_unit, file=trim(pixels_out), status='replace', action='write', iostat=ios) 254 + if (ios /= 0) then; close(in_unit); message = 'Cannot open ' // trim(pixels_out); return; end if 255 + 256 + read(in_unit, '(a)', iostat=ios) line_buf 257 + if (ios /= 0) then; close(in_unit); close(out_unit); message = 'Bad header'; return; end if 258 + read(line_buf(1:5), '(i5)') width 259 + read(line_buf(6:10), '(i5)') height 260 + 261 + if (width < 1 .or. width > MAX_COLS .or. height < 1) then 262 + message = 'Invalid dimensions'; close(in_unit); close(out_unit); return 263 + end if 264 + 265 + write(out_unit, '(a)') trim(line_buf) 266 + curr = 0; nxt = 0; out_row = 0 267 + 268 + call read_row(in_unit, width, nxt, ios) 269 + if (ios /= 0) then 270 + close(in_unit); close(out_unit); message = 'Bad first row'; return 271 + end if 272 + 273 + do row = 1, height 274 + curr(1:width) = nxt(1:width) 275 + nxt(1:width) = 0 276 + if (row < height) then 277 + call read_row(in_unit, width, nxt, ios) 278 + if (ios /= 0) nxt(1:width) = 0 279 + end if 280 + do col = 1, width 281 + old_val = curr(col) 282 + if (old_val < 0) old_val = 0 283 + if (old_val > 255) old_val = 255 284 + if (old_val >= 128) then; new_val = 255; else; new_val = 0; end if 285 + out_row(col) = new_val 286 + err = old_val - new_val 287 + if (col < width) curr(col+1) = curr(col+1) + err * 7 / 16 288 + if (col > 1) nxt(col-1) = nxt(col-1) + err * 3 / 16 289 + nxt(col) = nxt(col) + err * 5 / 16 290 + if (col < width) nxt(col+1) = nxt(col+1) + err * 1 / 16 291 + end do 292 + call write_row(out_unit, width, out_row) 293 + end do 294 + 295 + close(in_unit); close(out_unit) 296 + ok = .true.; message = 'Dither complete' 297 + end subroutine run_dither_for_display 298 + 299 + ! ---------------------------------------------------------------- 300 + ! render_pixels_terminal — read dithered pixel file and print using 301 + ! Unicode half-block characters for double vertical resolution. 302 + ! 303 + ! Half-block encoding (two pixel rows per terminal line): 304 + ! top=0, bot=0 → ' ' (space) 305 + ! top=1, bot=0 → '▀' (upper half block) 306 + ! top=0, bot=1 → '▄' (lower half block) 307 + ! top=1, bot=1 → '█' (full block) 308 + ! where 0=black (dithered on), 1=white (dithered off) 309 + ! ---------------------------------------------------------------- 310 + subroutine render_pixels_terminal(pixels_out, ok, message) 311 + character(len=*), intent(in) :: pixels_out 312 + logical, intent(out) :: ok 313 + character(len=*), intent(out) :: message 314 + 315 + integer :: width, height, row, col 316 + integer :: top_px, bot_px 317 + integer :: top_row(MAX_COLS), bot_row(MAX_COLS) 318 + character(len=4000) :: line_buf 319 + integer :: in_unit, ios 320 + character(len=:), allocatable :: out_line 321 + 322 + ok = .false.; message = 'render failed' 323 + 324 + open(newunit=in_unit, file=trim(pixels_out), status='old', action='read', iostat=ios) 325 + if (ios /= 0) then; message = 'Cannot open ' // trim(pixels_out); return; end if 326 + 327 + read(in_unit, '(a)', iostat=ios) line_buf 328 + if (ios /= 0) then; close(in_unit); return; end if 329 + read(line_buf(1:5), '(i5)') width 330 + read(line_buf(6:10), '(i5)') height 331 + 332 + ! Process two pixel rows at a time → one terminal line 333 + row = 0 334 + do while (row < height) 335 + ! Read top pixel row 336 + call read_row(in_unit, width, top_row, ios) 337 + if (ios /= 0) exit 338 + row = row + 1 339 + 340 + ! Read bottom pixel row (or use white if at last row) 341 + if (row < height) then 342 + call read_row(in_unit, width, bot_row, ios) 343 + if (ios /= 0) bot_row(1:width) = 255 344 + row = row + 1 345 + else 346 + bot_row(1:width) = 255 347 + end if 348 + 349 + ! Build output line using half-block chars 350 + out_line = '' 351 + do col = 1, width 352 + top_px = top_row(col) ! 0=black, 255=white 353 + bot_px = bot_row(col) 354 + 355 + if (top_px < 128 .and. bot_px < 128) then 356 + out_line = out_line // char(226) // char(150) // char(136) ! █ U+2588 357 + else if (top_px < 128 .and. bot_px >= 128) then 358 + out_line = out_line // char(226) // char(150) // char(128) ! ▀ U+2580 359 + else if (top_px >= 128 .and. bot_px < 128) then 360 + out_line = out_line // char(226) // char(150) // char(132) ! ▄ U+2584 361 + else 362 + out_line = out_line // ' ' 363 + end if 364 + end do 365 + 366 + write(*, '(a)') trim(out_line) 367 + end do 368 + 369 + close(in_unit) 370 + ok = .true.; message = 'Rendered' 371 + end subroutine render_pixels_terminal 230 372 231 373 end module dither_mod