├── .gitignore ├── fonts ├── Miama.otf ├── iosevka-regular.ttf ├── OpenSans-BoldItalic.ttf ├── VictorMono-Regular.ttf └── LICENSE.md ├── .gitmodules ├── README.md ├── first.jai ├── LICENSE ├── config_lexer.jai └── ditch.jai /.gitignore: -------------------------------------------------------------------------------- 1 | ditch 2 | .build/ 3 | -------------------------------------------------------------------------------- /fonts/Miama.otf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsoding/ditch/HEAD/fonts/Miama.otf -------------------------------------------------------------------------------- /fonts/iosevka-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsoding/ditch/HEAD/fonts/iosevka-regular.ttf -------------------------------------------------------------------------------- /fonts/OpenSans-BoldItalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsoding/ditch/HEAD/fonts/OpenSans-BoldItalic.ttf -------------------------------------------------------------------------------- /fonts/VictorMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tsoding/ditch/HEAD/fonts/VictorMono-Regular.ttf -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "twitch_irc"] 2 | path = twitch_irc 3 | url = git@github.com:tsoding/twitch_irc.git 4 | [submodule "unicode_utils"] 5 | path = unicode_utils 6 | url = git@github.com:rluba/jai-unicode.git 7 | [submodule "jason"] 8 | path = jason 9 | url = git@github.com:rluba/jason.git 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Ditch 2 | 3 | **WARNING! This software is UNFINISHED! Use it at your own risk! In fact, it may never be useful for anybody except the original author.** 4 | 5 | A simple Twitch Client. 6 | 7 | ## Quick Start 8 | 9 | ```console 10 | $ jai -version 11 | Version: beta 0.1.060, built on 8 April 2023. 12 | $ jai first.jai 13 | $ ./ditch 14 | ``` 15 | -------------------------------------------------------------------------------- /first.jai: -------------------------------------------------------------------------------- 1 | #import "Basic"; 2 | #import "Compiler"; 3 | 4 | #run { 5 | w := compiler_create_workspace("Ditch"); 6 | if !w { 7 | print("ERROR: Workspace creation failed.\n"); 8 | return; 9 | } 10 | 11 | bo := get_build_options(w); 12 | bo.output_executable_name = "ditch"; 13 | import_path : [..]string; 14 | array_add(*import_path, .. bo.import_path); 15 | array_add(*import_path, "."); 16 | bo.import_path = import_path; 17 | set_build_options(bo, w); 18 | add_build_file("ditch.jai", w); 19 | 20 | set_build_options_dc(.{do_output=false}); 21 | } 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Alexey Kutepov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /fonts/LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2023, Renzhi Li (aka. Belleve Invis, belleve@typeof.net) 2 | 3 | This Font Software is licensed under the SIL Open Font License, Version 1.1. 4 | 5 | This license is copied below, and is also available with a FAQ at: 6 | http://scripts.sil.org/OFL 7 | 8 | -------------------------- 9 | 10 | 11 | SIL Open Font License v1.1 12 | ==================================================== 13 | 14 | 15 | Preamble 16 | ---------- 17 | 18 | The goals of the Open Font License (OFL) are to stimulate worldwide 19 | development of collaborative font projects, to support the font creation 20 | efforts of academic and linguistic communities, and to provide a free and 21 | open framework in which fonts may be shared and improved in partnership 22 | with others. 23 | 24 | The OFL allows the licensed fonts to be used, studied, modified and 25 | redistributed freely as long as they are not sold by themselves. The 26 | fonts, including any derivative works, can be bundled, embedded, 27 | redistributed and/or sold with any software provided that any reserved 28 | names are not used by derivative works. The fonts and derivatives, 29 | however, cannot be released under any other type of license. The 30 | requirement for fonts to remain under this license does not apply 31 | to any document created using the fonts or their derivatives. 32 | 33 | 34 | Definitions 35 | ------------- 36 | 37 | `"Font Software"` refers to the set of files released by the Copyright 38 | Holder(s) under this license and clearly marked as such. This may 39 | include source files, build scripts and documentation. 40 | 41 | `"Reserved Font Name"` refers to any names specified as such after the 42 | copyright statement(s). 43 | 44 | `"Original Version"` refers to the collection of Font Software components as 45 | distributed by the Copyright Holder(s). 46 | 47 | `"Modified Version"` refers to any derivative made by adding to, deleting, 48 | or substituting -- in part or in whole -- any of the components of the 49 | Original Version, by changing formats or by porting the Font Software to a 50 | new environment. 51 | 52 | `"Author"` refers to any designer, engineer, programmer, technical 53 | writer or other person who contributed to the Font Software. 54 | 55 | 56 | Permission & Conditions 57 | ------------------------ 58 | 59 | Permission is hereby granted, free of charge, to any person obtaining 60 | a copy of the Font Software, to use, study, copy, merge, embed, modify, 61 | redistribute, and sell modified and unmodified copies of the Font 62 | Software, subject to the following conditions: 63 | 64 | 1. Neither the Font Software nor any of its individual components, 65 | in Original or Modified Versions, may be sold by itself. 66 | 67 | 2. Original or Modified Versions of the Font Software may be bundled, 68 | redistributed and/or sold with any software, provided that each copy 69 | contains the above copyright notice and this license. These can be 70 | included either as stand-alone text files, human-readable headers or 71 | in the appropriate machine-readable metadata fields within text or 72 | binary files as long as those fields can be easily viewed by the user. 73 | 74 | 3. No Modified Version of the Font Software may use the Reserved Font 75 | Name(s) unless explicit written permission is granted by the corresponding 76 | Copyright Holder. This restriction only applies to the primary font name as 77 | presented to the users. 78 | 79 | 4. The name(s) of the Copyright Holder(s) or the Author(s) of the Font 80 | Software shall not be used to promote, endorse or advertise any 81 | Modified Version, except to acknowledge the contribution(s) of the 82 | Copyright Holder(s) and the Author(s) or with their explicit written 83 | permission. 84 | 85 | 5. The Font Software, modified or unmodified, in part or in whole, 86 | must be distributed entirely under this license, and must not be 87 | distributed under any other license. The requirement for fonts to 88 | remain under this license does not apply to any document created 89 | using the Font Software. 90 | 91 | 92 | 93 | Termination 94 | ----------- 95 | 96 | This license becomes null and void if any of the above conditions are 97 | not met. 98 | 99 | 100 | DISCLAIMER 101 | 102 | THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 103 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF 104 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT 105 | OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE 106 | COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 107 | INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL 108 | DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING 109 | FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM 110 | OTHER DEALINGS IN THE FONT SOFTWARE. 111 | -------------------------------------------------------------------------------- /config_lexer.jai: -------------------------------------------------------------------------------- 1 | Loc :: struct { 2 | file_path: string; 3 | row: int; 4 | col: int; 5 | } 6 | 7 | Token_Kind :: enum { 8 | INVALID; 9 | SYMBOL; 10 | STRING; 11 | OCURLY; 12 | CCURLY; 13 | } 14 | 15 | Token :: struct { 16 | kind: Token_Kind; 17 | text: string; 18 | loc: Loc; 19 | } 20 | 21 | Lexer :: struct { 22 | content: string; 23 | file_path: string; 24 | row, bol, cur: int; 25 | sb: String_Builder; 26 | 27 | peeked: bool; 28 | peeked_token: Token; 29 | peeked_result: Lexer_Result; 30 | } 31 | 32 | lexer_new :: (content: string, file_path := "") -> Lexer { 33 | lexer: Lexer; 34 | lexer.content = content; 35 | lexer.file_path = file_path; 36 | return lexer; 37 | } 38 | 39 | Lexer_Result :: enum { 40 | OK; 41 | EOF; 42 | INVALID; 43 | } 44 | 45 | Diag_Level :: enum { 46 | NOTE; 47 | WARNING; 48 | ERROR; 49 | } 50 | 51 | // TODO: maybe integrate with Jai's logger and Location? 52 | log_diag_at :: (using loc: Loc, level: Diag_Level, format: string, args: .. Any) { 53 | builder: String_Builder; 54 | builder.allocator = temp; 55 | print_to_builder(*builder, "%:%:%: ", file_path, row + 1, col + 1); 56 | if #complete level == { 57 | case .NOTE; print_to_builder(*builder, "NOTE: "); 58 | case .WARNING; print_to_builder(*builder, "WARNING: "); 59 | case .ERROR; print_to_builder(*builder, "ERROR: "); 60 | } 61 | print_to_builder(*builder, format, .. args); 62 | log_error(builder_to_string(*builder)); 63 | } 64 | 65 | lexer_peek :: (using l: *Lexer) -> Token, Lexer_Result { 66 | if !peeked { 67 | peeked_token, peeked_result = lexer_chop_token(l); 68 | peeked = true; 69 | } 70 | 71 | return peeked_token, peeked_result; 72 | } 73 | 74 | lexer_next :: (using l: *Lexer) -> Token, Lexer_Result { 75 | if peeked { 76 | peeked = false; 77 | return peeked_token, peeked_result; 78 | } 79 | 80 | token, result := lexer_chop_token(l); 81 | return token, result; 82 | } 83 | 84 | lexer_expect_token :: (using l: *Lexer, expected: .. Token_Kind) -> Token, bool { 85 | token, result := lexer_next(l); 86 | if #complete result == { 87 | case .OK; { 88 | for expected { 89 | if it == token.kind { 90 | return token, true; 91 | } 92 | } 93 | for expected { 94 | if it_index > 0 then print_to_builder(*sb, " or "); 95 | print_to_builder(*sb, "%", it); 96 | } 97 | log_diag_at(token.loc, .ERROR, "expected % but got %", builder_to_string(*sb), token.kind); 98 | return token, false; 99 | } 100 | case .INVALID; return token, false; 101 | case .EOF; { 102 | for expected { 103 | if it_index > 0 then print_to_builder(*sb, " or "); 104 | print_to_builder(*sb, "%", it); 105 | } 106 | log_diag_at(token.loc, .ERROR, "expected % but got end of file", builder_to_string(*sb)); 107 | return token, false; 108 | } 109 | } 110 | } 111 | 112 | #scope_file 113 | 114 | lexer_chop_token :: (using l: *Lexer) -> Token, Lexer_Result { 115 | lexer_trim_left(l); 116 | while !lexer_is_empty(l) && lexer_starts_with(l, "//") { 117 | lexer_drop_line(l); 118 | lexer_trim_left(l); 119 | } 120 | 121 | t: Token; 122 | t.loc = lexer_loc(l); 123 | 124 | if lexer_is_empty(l) return t, .EOF; 125 | 126 | if is_symbol_start(content[cur]) { 127 | t.kind = .SYMBOL; 128 | while !lexer_is_empty(l) && is_symbol(content[cur]) { 129 | append(*sb, content[cur]); 130 | lexer_chop_chars(l); 131 | } 132 | t.text = builder_to_string(*sb); 133 | return t, .OK; 134 | } 135 | 136 | if content[cur] == #char "\"" { 137 | t.kind = .STRING; 138 | 139 | lexer_chop_chars(l); 140 | while !lexer_is_empty(l) { 141 | if content[cur] == { 142 | case #char "\""; break; 143 | case #char "\\"; { 144 | lexer_chop_chars(l); 145 | if lexer_is_empty(l) { 146 | log_diag_at(lexer_loc(l), .ERROR, "unfinished escape sequence"); 147 | return t, .INVALID; 148 | } 149 | 150 | if content[cur] == { 151 | case #char "\""; { 152 | append(*sb, #char "\""); 153 | lexer_chop_chars(l); 154 | } 155 | case; { 156 | log_diag_at(lexer_loc(l), .ERROR, "unknown escape sequence starts with %", slice(content, cur, 1)); 157 | return t, .INVALID; 158 | } 159 | } 160 | } 161 | case; { 162 | append(*sb, content[cur]); 163 | lexer_chop_chars(l); 164 | } 165 | } 166 | } 167 | 168 | t.text = builder_to_string(*sb); 169 | 170 | if lexer_is_empty(l) { 171 | log_diag_at(lexer_loc(l), .ERROR, "unfinished string literal"); 172 | log_diag_at(t.loc, .NOTE, "the literal starts here"); 173 | return t, .INVALID; 174 | } 175 | 176 | lexer_chop_chars(l); 177 | 178 | return t, .OK; 179 | 180 | } 181 | 182 | delims : []struct { prefix: string; kind: Token_Kind; } = .[ 183 | .{ prefix = "{", kind = .OCURLY }, 184 | .{ prefix = "}", kind = .CCURLY }, 185 | ]; 186 | 187 | for delims { 188 | if lexer_starts_with(l, it.prefix) { 189 | lexer_chop_chars(l, it.prefix.count); 190 | t.kind = it.kind; 191 | t.text = it.prefix; 192 | return t, .OK; 193 | } 194 | } 195 | 196 | log_diag_at(t.loc, .ERROR, "invalid token starts with %", slice(content, cur, 1)); 197 | lexer_chop_chars(l); 198 | return t, .INVALID; 199 | } 200 | 201 | lexer_is_empty :: (using l: *Lexer) -> bool { 202 | return cur >= content.count; 203 | } 204 | 205 | lexer_trim_left :: (using l: *Lexer) { 206 | while !lexer_is_empty(l) && is_space(content[cur]) { 207 | lexer_chop_chars(l); 208 | } 209 | } 210 | 211 | lexer_starts_with :: (using l: *Lexer, prefix: string) -> bool { 212 | assert(cur <= content.count); 213 | return starts_with(slice(content, cur, content.count - cur), prefix); 214 | } 215 | 216 | lexer_drop_line :: (using l: *Lexer) { 217 | while !lexer_is_empty(l) && content[cur] != #char "\n" { 218 | lexer_chop_chars(l); 219 | } 220 | 221 | if !lexer_is_empty(l) { 222 | lexer_chop_chars(l); 223 | } 224 | } 225 | 226 | lexer_loc :: (using l: *Lexer) -> Loc { 227 | return .{ 228 | file_path = file_path, 229 | row = row, 230 | col = cur - bol, 231 | }; 232 | } 233 | 234 | is_symbol_start :: (x: u8) -> bool { 235 | if #char "a" <= x && x <= #char "z" return true; 236 | if #char "A" <= x && x <= #char "Z" return true; 237 | return x == #char "_"; 238 | } 239 | 240 | is_symbol :: (x: u8) -> bool { 241 | if is_symbol_start(x) return true; 242 | return #char "0" <= x && x <= #char "9"; 243 | } 244 | 245 | lexer_chop_chars :: (using l: *Lexer, n := 1) { 246 | while !lexer_is_empty(l) && n > 0 { 247 | x := content[cur]; 248 | cur += 1; 249 | if x == #char "\n" { 250 | row += 1; 251 | bol = cur; 252 | } 253 | n -= 1; 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /ditch.jai: -------------------------------------------------------------------------------- 1 | Twitch_Stream :: struct { 2 | title: string; 3 | user_name: string; 4 | viewer_count: int; 5 | } 6 | 7 | Twitch_User :: struct { 8 | id: string; 9 | login: string; 10 | display_name: string; 11 | } 12 | 13 | Twitch_Payload :: struct(T: Type) { 14 | data: []T; 15 | } 16 | 17 | Chat_Message :: struct { 18 | nick: string; 19 | message: string; 20 | } 21 | 22 | Config :: struct { 23 | username: string; 24 | oauth_token: string; 25 | auto_join: string; 26 | client_id: string; 27 | } 28 | 29 | window: Window_Type; 30 | window_width, window_height: int; 31 | my_font: *Font; 32 | 33 | chat_log: [..]Chat_Message; 34 | twitch: Twitch_Chat([..]Chat_Message); 35 | 36 | current_user: Twitch_User; 37 | current_stream: Twitch_Stream; 38 | current_stream_mutex: Mutex; 39 | current_config: Config; 40 | current_theme: s32 = xx Default_Themes.Grayscale; 41 | 42 | current_time: float64; 43 | last_time: float64; 44 | 45 | chat_input: string; 46 | chat_input_first_activate := false; 47 | 48 | hexcode :: (code: string) -> Vector4 { 49 | value := string_to_int(code, base = 16, T = u64); 50 | a := ((value>>(8*0))&0xFF)/255.0; 51 | b := ((value>>(8*1))&0xFF)/255.0; 52 | g := ((value>>(8*2))&0xFF)/255.0; 53 | r := ((value>>(8*3))&0xFF)/255.0; 54 | return .{r, g, b, a}; 55 | } 56 | 57 | BACKGROUND_COLOR :: #run hexcode("121f05ff"); 58 | INFO_COLOR :: #run hexcode("c8f67aff"); 59 | NICK_COLOR :: #run hexcode("c4d533ff"); 60 | MESSAGE_COLOR :: #run hexcode("ffffffff"); 61 | SHADOW_COLOR :: #run hexcode("181818FF"); 62 | 63 | main :: () { 64 | directory_of_running_executable := path_strip_filename(get_path_of_running_executable()); 65 | set_working_directory(directory_of_running_executable); 66 | 67 | current_config = parse_config(); 68 | current_user = query_current_user(current_config); 69 | 70 | window_width = 800; 71 | window_height = 600; 72 | window_title :: "Ditch"; 73 | window = create_window(window_width, window_height, window_title); 74 | Simp.set_render_target(window); 75 | 76 | reload_fonts(); 77 | ui_init(); 78 | 79 | init(*current_stream_mutex); 80 | viewer_count_thread: Thread; 81 | ok := thread_init(*viewer_count_thread, (thread: *Thread) -> int { 82 | while true { 83 | stream, ok := query_current_stream(current_config); 84 | if ok { 85 | lock(*current_stream_mutex); 86 | current_stream = stream; 87 | unlock(*current_stream_mutex); 88 | } 89 | 90 | sleep_milliseconds(5000); 91 | } 92 | 93 | return 0; 94 | }); 95 | if ok { 96 | thread_start(*viewer_count_thread); 97 | } else { 98 | log_error("ERROR: Could not initialize the Viewer Count Polling Thread"); 99 | } 100 | 101 | event_callback :: (event: Twitch_Event, chat_log: *[..]Chat_Message) { 102 | if event.type == { 103 | case .AUTHENTICATED; { 104 | cmd_join(*twitch, current_config.auto_join); 105 | } 106 | case .PRIVMSG; { 107 | array_add(chat_log, .{ 108 | nick = copy_string(get_tag_value(event.tags, "display-name")), 109 | message = copy_string(event.message) // TODO: some sort of special allocator for the messages? 110 | }); 111 | } 112 | } 113 | } 114 | 115 | ok = init(*twitch, event_callback, *chat_log, verbose = true); 116 | if !ok { 117 | log_error("ERROR: Could not initialize Twitch connection"); 118 | // Realistically as of right now this can only happen on Windows when WSAStartup() fails. 119 | // I'm not sure what to do in that case, so let's just exit; 120 | exit(1); 121 | } 122 | defer deinit(*twitch); 123 | 124 | // TODO: Move SSL initialization to twitch_irc 125 | ret := OpenSSL_add_all_algorithms(); 126 | assert(ret == 1); 127 | ret = SSL_load_error_strings(); 128 | assert(ret == 1); 129 | ctx := SSL_CTX_new(TLS_client_method()); 130 | if ctx == null { 131 | print("ERROR: could not initialize SSL context\n"); 132 | exit(1); 133 | } 134 | 135 | ok = connect(*twitch, current_config.username, tprint("oauth:%", current_config.oauth_token), port = "6697", ssl_ctx = ctx); 136 | if !ok { 137 | log_error("ERROR: Could not connect to Twitch"); 138 | exit(1); 139 | } 140 | 141 | quit := false; 142 | while !quit { 143 | current_time = get_time(); 144 | dt := cast(float)(current_time - last_time); 145 | Clamp(*dt, 0, 0.1); 146 | last_time = current_time; 147 | 148 | 149 | if twitch.status != .DISCONNECTED { 150 | ok = update(*twitch, 0); 151 | } 152 | 153 | Input.update_window_events(); 154 | 155 | for Input.get_window_resizes() { 156 | Simp.update_window(it.window); 157 | 158 | if it.window == window { 159 | if (it.width != window_width) || (it.height != window_height) { 160 | window_width = it.width; 161 | window_height = it.height; 162 | reload_fonts(); // Resize the font for the new window size. 163 | } 164 | } 165 | } 166 | 167 | for event: Input.events_this_frame { 168 | if event.type == .QUIT then quit = true; 169 | 170 | getrect_handle_event(event); 171 | 172 | if event.type == { 173 | case .KEYBOARD; 174 | if event.key_pressed { 175 | if event.key_code == { 176 | case #char "T"; if event.ctrl_pressed { 177 | set_current_title_to(current_config, os_clipboard_get_text()); 178 | } 179 | } 180 | } 181 | } 182 | } 183 | 184 | lock(*current_stream_mutex); 185 | local_copy_of_current_stream_for_thread_safety := current_stream; 186 | unlock(*current_stream_mutex); 187 | 188 | draw_one_frame(local_copy_of_current_stream_for_thread_safety); 189 | 190 | reset_temporary_storage(); 191 | sleep_milliseconds(10); 192 | } 193 | } 194 | 195 | draw_one_frame :: (stream: Twitch_Stream) { 196 | proc := default_theme_procs[current_theme]; 197 | my_theme := proc(); 198 | set_default_theme(my_theme); // Just in case we don't explicitly pass themes sometimes...! 199 | 200 | bg_col := my_theme.background_color; 201 | Simp.clear_render_target(bg_col.x, bg_col.y, bg_col.z, 1); 202 | 203 | x, y, width, height := get_dimensions(window, true); 204 | ui_per_frame_update(window, width, height, current_time); 205 | 206 | MESSAGE_PADDING :: 10; 207 | 208 | modify_string :: (ps: *string, new_value: string) { 209 | free(< ", it.nick); 245 | nick_width := Simp.prepare_text(my_font, nick_text); 246 | Simp.draw_prepared_text(my_font, MESSAGE_PADDING, y + MESSAGE_PADDING, NICK_COLOR); 247 | 248 | message_text := it.message; 249 | Simp.prepare_text(my_font, message_text); 250 | Simp.draw_prepared_text(my_font, MESSAGE_PADDING + nick_width, y + MESSAGE_PADDING, MESSAGE_COLOR); 251 | } 252 | 253 | { 254 | label_theme := my_theme.label_theme; 255 | label_theme.font = my_font; 256 | label_theme.alignment = .Right; 257 | label_height := my_font.character_height*1.5; 258 | 259 | SHADOW_OFFSET :: 2; 260 | 261 | viewer_count_text := tprint("Viewers: %", stream.viewer_count); 262 | 263 | label_theme.text_color = SHADOW_COLOR; 264 | r = get_rect(0 - SHADOW_OFFSET, window_height - label_height - SHADOW_OFFSET, xx window_width, label_height); 265 | label(r, viewer_count_text, *label_theme); 266 | 267 | label_theme.text_color = INFO_COLOR; 268 | r = get_rect(0, window_height - label_height, xx window_width, label_height); 269 | label(r, viewer_count_text, *label_theme); 270 | 271 | title_text := tprint("Title: %", stream.title); 272 | 273 | label_theme.text_color = SHADOW_COLOR; 274 | r = get_rect(0 - SHADOW_OFFSET, window_height - label_height*2 - SHADOW_OFFSET, xx window_width, label_height); 275 | label(r, title_text, *label_theme); 276 | 277 | label_theme.text_color = INFO_COLOR; 278 | r = get_rect(0, window_height - label_height*2, xx window_width, label_height); 279 | label(r, title_text, *label_theme); 280 | 281 | } 282 | 283 | Simp.swap_buffers(window); 284 | } 285 | 286 | #if OS == .WINDOWS { 287 | PATH_SEPARATOR_STRING :: "\\"; 288 | } else #if OS == .LINUX { 289 | PATH_SEPARATOR_STRING :: "/"; 290 | } else #if OS == .MACOS { 291 | PATH_SEPARATOR_STRING :: "/"; 292 | } 293 | 294 | parse_config :: () -> Config, bool { 295 | config: Config; 296 | 297 | home_dir, ok := get_home_directory(); 298 | if !ok { 299 | log_error("ERROR: Could not get home folder for some reason"); 300 | return config, false; 301 | } 302 | 303 | ditch_dir := join(home_dir, ".ditch", separator = PATH_SEPARATOR_STRING); 304 | ok = make_directory_if_it_does_not_exist(ditch_dir); 305 | if !ok { 306 | log_error("ERROR: Could not create folder %", ditch_dir); 307 | return config, false; 308 | } 309 | 310 | ditch_conf_path := join(ditch_dir, "ditch.conf", separator = PATH_SEPARATOR_STRING); 311 | if !file_exists(ditch_conf_path) { 312 | config.username = "example"; 313 | config.auto_join = "#example"; 314 | ok = write_entire_file(ditch_conf_path, tprint(#string CONF 315 | // -*- mode: c -*- 316 | // Your Twitch username and and the channel to auto join. Keep in mind that the channel must start with # 317 | username "%" 318 | auto_join "%" 319 | 320 | // You can get oauth_token and client_id at https://tsoding.github.io/kgbotka-login/ 321 | oauth_token "" 322 | client_id "" 323 | // vim: set filetype=c : 324 | CONF, config.username, config.auto_join)); 325 | if !ok { 326 | log_error("ERROR: Could not generate default %", ditch_conf_path); 327 | return config, false; 328 | } 329 | log("INFO: Generated default %", ditch_conf_path); 330 | return config, true; 331 | } 332 | 333 | ditch_content: string; 334 | ditch_content, ok = read_entire_file(ditch_conf_path); 335 | if !ok { 336 | log_error("ERROR: Could not read file %", ditch_conf_path); 337 | return config, false; 338 | } 339 | lexer := lexer_new(ditch_content, ditch_conf_path); 340 | 341 | token, result := lexer_next(*lexer); 342 | while result == .OK { 343 | if token.kind == .SYMBOL { 344 | if token.text == { 345 | case "username"; { 346 | token, ok = lexer_expect_token(*lexer, .STRING); 347 | if !ok return config, false; 348 | config.username = token.text; 349 | } 350 | case "oauth_token"; { 351 | token, ok = lexer_expect_token(*lexer, .STRING); 352 | if !ok return config, false; 353 | config.oauth_token = token.text; 354 | } 355 | case "auto_join"; { 356 | token, ok = lexer_expect_token(*lexer, .STRING); 357 | if !ok return config, false; 358 | config.auto_join = token.text; 359 | } 360 | case "client_id"; { 361 | token, ok = lexer_expect_token(*lexer, .STRING); 362 | if !ok return config, false; 363 | config.client_id = token.text; 364 | } 365 | case; { 366 | log_diag_at(token.loc, .ERROR, "Unknown command %", token.text); 367 | return config, false; 368 | } 369 | } 370 | token, result = lexer_next(*lexer); 371 | } else { 372 | log_diag_at(token.loc, .ERROR, "expected % but got %", Token_Kind.SYMBOL, token.kind); 373 | return config, false; 374 | } 375 | } 376 | 377 | if result != .EOF { 378 | return config, false; 379 | } 380 | 381 | return config, true; 382 | } 383 | 384 | set_current_title_to :: (config: Config, title: string) -> bool { 385 | Title_Patch :: struct { 386 | title: string; 387 | } 388 | 389 | url := tprint("https://api.twitch.tv/helix/channels?broadcaster_id=%", current_user.id); 390 | data := json_write_string(Title_Patch.{ 391 | title = title 392 | }); 393 | 394 | ok, content, code := request(.PATCH, url, config.client_id, config.oauth_token, data = data); 395 | if !ok { 396 | log_error("ERROR: Could not patch current title at %", url); 397 | return false; 398 | } 399 | 400 | if code >= 400 { 401 | log_error("ERROR: Query to % return code %: %", url, code, content); 402 | return false; 403 | } 404 | 405 | log("INFO: Successfully set the title to \"%\"", title); 406 | return true; 407 | } 408 | 409 | get_first_twitch_payload :: ($Payload: Type, config: Config, url: string, data: string) -> Payload, bool { 410 | ok, content, code := request(.GET, url, config.client_id, config.oauth_token, data = data); 411 | if !ok { 412 | log_error("ERROR: Could not query % via %", Type, url); 413 | return .{}, false; 414 | } 415 | 416 | if code >= 400 { 417 | log_error("ERROR: Query to % return code %: %", url, code, content); 418 | return .{}, false; 419 | } 420 | 421 | payload : Twitch_Payload(Payload); 422 | ok, payload = json_parse_string(content, type_of(payload)); 423 | if !ok { 424 | log_error("ERROR: Could not parse Twitch payload from %: %", url, content); 425 | return .{}, false; 426 | } 427 | 428 | if payload.data.count <= 0 { 429 | log_error("ERROR: Twitch payload from % does not contain any %", url, Payload); 430 | return .{}, false; 431 | } 432 | 433 | return payload.data[0], true; 434 | } 435 | 436 | query_current_stream :: (config: Config) -> Twitch_Stream, bool { 437 | url :: "https://api.twitch.tv/helix/streams"; 438 | data := tprint("user_login=%", config.username); 439 | stream, ok := get_first_twitch_payload(Twitch_Stream, config, url, data); 440 | return stream, ok; 441 | } 442 | 443 | query_current_user :: (config: Config) -> Twitch_User, bool { 444 | url :: "https://api.twitch.tv/helix/users"; 445 | data := tprint("login=%", config.username); 446 | user, ok := get_first_twitch_payload(Twitch_User, config, url, data); 447 | return user, ok; 448 | } 449 | 450 | reload_fonts :: () -> bool { 451 | font_file_name :: "iosevka-regular.ttf"; 452 | // font_file_name :: "VictorMono-Regular.ttf"; 453 | // font_file_name :: "Miama.otf"; 454 | // font_file_name :: "OpenSans-BoldItalic.ttf"; 455 | pixel_height := window_height / 24; 456 | my_font = Simp.get_font_at_size("fonts", font_file_name, pixel_height); 457 | if my_font == null { 458 | log_error("ERROR: Could not load font % with size %", font_file_name, pixel_height); 459 | return false; 460 | } 461 | return true; 462 | } 463 | 464 | // Curl expects a C function, that's why we have to use #c_call, and since #c_call doesn't provide a context and JAI function need it, we push_context 465 | write_callback :: (contents: *u8, count: u64, size: u64, builder: *String_Builder) -> u64 #c_call { 466 | total_size := count * size; 467 | new_context: Context; 468 | push_context new_context { 469 | // Append to the builder 470 | append(builder, contents, cast(s64) total_size); 471 | } 472 | return total_size; 473 | } 474 | 475 | // Enumerations for different REST methods 476 | Rest_Method :: enum { 477 | GET; 478 | POST; 479 | PUT; 480 | DELETE; 481 | PATCH; 482 | } 483 | 484 | // Here we use a baked Rest_Method, this means variable method must be known on compile time, 485 | // but that is fine since most likely you wouldn't change the method on runtime. 486 | // This allows us to combine multiple functions into one, and cut on typing the same code multiple times, 487 | // the way we distpatch between different methods is using #if keyword 488 | request :: ($method: Rest_Method, url: string, client_id: string, oauth_token: string, data := "") -> bool, string, int { 489 | #import "Curl"; 490 | 491 | // Init Curl and setup a deferred cleanup 492 | curl := curl_easy_init(); 493 | if !curl { 494 | log_error("ERROR: An error occured while initting up the curl connection, but Curl doesn't tell us why."); 495 | return false, "", 0; 496 | } 497 | defer curl_easy_cleanup(curl); 498 | 499 | // Init string builder, so we can output a generic string 500 | builder: String_Builder; 501 | builder.allocator = temp; // We are not planning to keep the string long term, so we will use temp allocator, in a real application that decision has to be made for yourself 502 | 503 | curl_easy_setopt(curl, .WRITEFUNCTION, write_callback); 504 | curl_easy_setopt(curl, .WRITEDATA, *builder); 505 | 506 | headerlist := curl_slist_append(null, tprint("Client-Id: %\0", client_id).data); 507 | headerlist = curl_slist_append(headerlist, tprint("Authorization: Bearer %\0", oauth_token).data); 508 | headerlist = curl_slist_append(headerlist, "Content-Type: application/json\0".data); 509 | curl_easy_setopt(curl, .HTTPHEADER, headerlist); 510 | defer curl_slist_free_all(headerlist); 511 | 512 | // Set target URL 513 | #if method == .GET { 514 | // Add request data to the url, since GET requests expect it there 515 | // Maybe this shouldn't be included in the example, for the sake of reducing complexity (mandate that data is passed in the url for the get request) 516 | url_builder: String_Builder; 517 | 518 | append(*url_builder, url); 519 | if !ends_with(url, "/") && !ends_with(url, "\\") { 520 | append(*url_builder, "/"); 521 | } 522 | append(*url_builder, "?"); 523 | append(*url_builder, data); 524 | 525 | // Ideally we would only do one temp allocation here, something like builder_to_cstring 526 | curl_easy_setopt(curl, .URL, temp_c_string(builder_to_string(*url_builder, temp))); 527 | } else { 528 | curl_easy_setopt(curl, .URL, temp_c_string(url)); 529 | } 530 | 531 | // Pass request data 532 | #if method == .GET { 533 | // Already set up in the URL! 534 | } else if method == .POST { 535 | curl_easy_setopt(curl, .POST, 1); 536 | if data curl_easy_setopt(curl, .POSTFIELDS, temp_c_string(data)); 537 | } else if method == .PUT { 538 | curl_easy_setopt(curl, .CUSTOMREQUEST, "PUT"); 539 | if data curl_easy_setopt(curl, .POSTFIELDS, temp_c_string(data)); 540 | } else if method == .DELETE { 541 | curl_easy_setopt(curl, .CUSTOMREQUEST, "DELETE"); 542 | if data curl_easy_setopt(curl, .POSTFIELDS, temp_c_string(data)); 543 | } else if method == .PATCH { 544 | curl_easy_setopt(curl, .CUSTOMREQUEST, "PATCH"); 545 | if data curl_easy_setopt(curl, .POSTFIELDS, temp_c_string(data)); 546 | } 547 | 548 | // Perform the "easy" action 549 | error_code := curl_easy_perform(curl); 550 | if error_code != .OK { 551 | error_message := to_string(curl_easy_strerror(error_code)); 552 | defer free(error_message); 553 | log_error("Curl Error: %", error_message); 554 | return false, "", 0; 555 | } 556 | 557 | http_code: int; 558 | error_code = curl_easy_getinfo(curl, .RESPONSE_CODE, *http_code); 559 | assert(error_code == .OK, "I assume for now that error_code != .OK only if curl_easy_perform has failed. (Which might be a wrong assumption. That's why we have this assert in here)"); 560 | 561 | // In a real application you would most likely use default allocator, 562 | // here we only intend to use the string short term 563 | return true, builder_to_string(*builder, temp), http_code; 564 | } 565 | 566 | #import "Basic"; 567 | #import "File_Utilities"; 568 | #import "Window_Creation"; 569 | #import "GetRect"; 570 | Simp :: #import "Simp"; 571 | Font :: Simp.Dynamic_Font; 572 | #import "Math"; 573 | #import "String"; 574 | #import "System"; 575 | #import "Thread"; 576 | #import "Atomics"; 577 | #import "Clipboard"; 578 | Input :: #import "Input"; 579 | #load "twitch_irc/module.jai"; 580 | #load "jason/module.jai"; 581 | #load "config_lexer.jai"; 582 | 583 | // TODO: consider utilizing GLOBALUSERSTATE instead of query_current_user() to get the user-id 584 | // TODO: set the title via the chat text input 585 | --------------------------------------------------------------------------------