├── .drone.yml ├── .gitignore ├── LICENSE ├── README.md ├── shard.yml ├── spec ├── file_handler │ ├── add_spec.cr │ └── delete_spec.cr └── file_handler_spec.cr └── src ├── cli.cr ├── editor.cr ├── editor ├── add.cr ├── delete.cr └── move.cr ├── file_handler.cr ├── file_handler ├── add.cr └── delete.cr ├── libc └── ioctls.cr ├── terminal.cr └── terminal ├── color.cr ├── input.cr └── render.cr /.drone.yml: -------------------------------------------------------------------------------- 1 | kind: pipeline 2 | name: default 3 | 4 | platform: 5 | os: linux 6 | arch: amd64 7 | 8 | steps: 9 | - name: format 10 | image: crystallang/crystal:latest-alpine 11 | commands: 12 | - crystal tool format --check 13 | 14 | - name: build 15 | image: crystallang/crystal:latest-alpine 16 | commands: 17 | - shards install 18 | - shards build --static 19 | 20 | - name: test 21 | image: crystallang/crystal:latest-alpine 22 | commands: 23 | - crystal spec --warnings all --error-on-warnings 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin 2 | /lib 3 | /shard.lock 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2018-2021 Julien Reichardt 4 | 5 | Permission to use, copy, modify, and distribute this software for any 6 | purpose with or without fee is hereby granted, provided that the above 7 | copyright notice and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cride 2 | 3 | [![Build Status](https://cloud.drone.io/api/badges/j8r/cride/status.svg)](https://cloud.drone.io/j8r/cride) 4 | [![ISC](https://img.shields.io/badge/License-ISC-blue.svg?style=flat-square)](https://en.wikipedia.org/wiki/ISC_license) 5 | 6 | A light Crystal IDE 7 | 8 | ![screenshot](https://i.imgur.com/UCSsnDz.png) 9 | 10 | ## Features 11 | 12 | * Light, fast and easy to use 13 | * Customizable 14 | * Modular (different front-ends can share same resources) 15 | * Colors 16 | * Read from the stdin 17 | * Advanced CTRL + Arrow keys traversing 18 | * CTRL+D line duplication 19 | * CTRL+K line emptying/deletion 20 | 21 | ## Command usage 22 | 23 | You have to build Cride (see the **Development** section below). 24 | 25 | Open a file: 26 | 27 | `bin/cride README.md` 28 | 29 | ## Development 30 | 31 | Build Cride: 32 | 33 | `shards build` 34 | 35 | ## Build with Docker 36 | 37 | To build a statically-linked `cride` binary: 38 | 39 | ```sh 40 | docker run -it --rm -v $PWD:/app -w /app crystal:latest-alpine sh -c "\ 41 | shards build --static --release 42 | chown 1000:1000 bin/cride" 43 | ``` 44 | 45 | The binary built is `bin/cride`. 46 | 47 | ## License 48 | 49 | Copyright (c) 2018-2021 Julien Reichardt - ISC License 50 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: cride 2 | version: 0.2.2 3 | 4 | authors: 5 | - Julien Reichardt 6 | 7 | description: A light Crystal IDE 8 | 9 | targets: 10 | cride: 11 | main: src/cli.cr 12 | 13 | license: ISC 14 | -------------------------------------------------------------------------------- /spec/file_handler/add_spec.cr: -------------------------------------------------------------------------------- 1 | class Cride::FileHandler 2 | end 3 | 4 | require "spec" 5 | require "../../src/file_handler/add.cr" 6 | 7 | describe Cride::FileHandler::Add do 8 | saved = false 9 | 10 | it "modifies a char" do 11 | rows = ["some", "sample", "text"] 12 | file_add = Cride::FileHandler::Add.new rows 13 | file_add.set_char 3, 1, 'A' 14 | rows.should eq ["some", "samAle", "text"] 15 | end 16 | 17 | it "adds a char" do 18 | rows = ["some", "sample", "text"] 19 | file_add = Cride::FileHandler::Add.new rows 20 | file_add.char 3, 1, 'A' 21 | rows.should eq ["some", "samAple", "text"] 22 | end 23 | 24 | it "adds a line" do 25 | rows = ["some", "sample", "text"] 26 | file_add = Cride::FileHandler::Add.new rows 27 | file_add.line 3, 1 28 | rows.should eq ["some", "sam", "ple", "text"] 29 | end 30 | 31 | it "duplicates a line" do 32 | rows = ["some", "dup", "text"] 33 | file_add = Cride::FileHandler::Add.new rows 34 | file_add.duplicate_line 1 35 | rows.should eq ["some", "dup", "dup", "text"] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/file_handler/delete_spec.cr: -------------------------------------------------------------------------------- 1 | class Cride::FileHandler 2 | end 3 | 4 | require "spec" 5 | require "../../src/file_handler/delete" 6 | 7 | describe Cride::FileHandler::Delete do 8 | saved = false 9 | 10 | it "deletes a char" do 11 | rows = ["some", "sample", "text"] 12 | file_del = Cride::FileHandler::Delete.new rows 13 | file_del.char 3, 1 14 | rows.should eq ["some", "samle", "text"] 15 | end 16 | 17 | it "deletes a line and append the rest to the previous one" do 18 | rows = ["some", "sample", "text"] 19 | file_del = Cride::FileHandler::Delete.new rows 20 | file_del.next_line_append_previous 1 21 | rows.should eq ["some", "sampletext"] 22 | end 23 | 24 | it "deletes a line" do 25 | rows = ["some", "sample", "text"] 26 | file_del = Cride::FileHandler::Delete.new rows 27 | file_del.line 1 28 | rows.should eq ["some", "text"] 29 | end 30 | 31 | it "clears a line" do 32 | rows = ["some", "sample", "text"] 33 | file_del = Cride::FileHandler::Delete.new rows 34 | file_del.clear_line 1 35 | rows.should eq ["some", "", "text"] 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /spec/file_handler_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/file_handler" 3 | 4 | SAMPLE_DATA = <<-DATA 5 | this 6 | is\n 7 | sample data\n 8 | DATA 9 | 10 | SAMPLE_FILE_PATH = File.tempname(suffix: "file_sample_test") 11 | 12 | describe Cride::FileHandler do 13 | describe "parses a file sample" do 14 | File.write SAMPLE_FILE_PATH, SAMPLE_DATA 15 | file = Cride::FileHandler.read SAMPLE_FILE_PATH 16 | 17 | it "checks rows size" do 18 | file.rows.size.should eq SAMPLE_DATA.lines.size + 1 19 | end 20 | 21 | it "checks file name" do 22 | file.name.should eq SAMPLE_FILE_PATH 23 | end 24 | 25 | it "should be saved" do 26 | file.saved?.should be_true 27 | end 28 | 29 | it "to_s againt the original sample data" do 30 | file.to_s.should eq SAMPLE_DATA 31 | end 32 | 33 | it "writes to the disk" do 34 | file.write 35 | File.read(SAMPLE_FILE_PATH).should eq SAMPLE_DATA 36 | file.saved?.should be_true 37 | end 38 | 39 | it "is not saved" do 40 | file.rows << "" 41 | file.saved?.should be_false 42 | end 43 | 44 | it "is saved" do 45 | file.rows << "" 46 | file.saved?.should be_false 47 | file.write 48 | file.saved?.should be_true 49 | end 50 | ensure 51 | File.delete SAMPLE_FILE_PATH 52 | end 53 | 54 | it "parses emtpy data" do 55 | empty_file = Cride::FileHandler.new 56 | empty_file.rows.should eq [""] 57 | empty_file.name.should be_nil 58 | empty_file.saved?.should be_false 59 | empty_file.to_s.should eq "" 60 | end 61 | 62 | it "parses from io" do 63 | from_io = Cride::FileHandler.new IO::Memory.new(SAMPLE_DATA) 64 | from_io.rows.size.should eq SAMPLE_DATA.lines.size + 1 65 | from_io.name.should be_nil 66 | from_io.saved?.should be_false 67 | end 68 | 69 | it "parses a sample data string" do 70 | file_data = Cride::FileHandler.new SAMPLE_DATA 71 | file_data.rows.size.should eq SAMPLE_DATA.lines.size + 1 72 | file_data.name.should be_nil 73 | file_data.saved?.should be_false 74 | end 75 | 76 | it "parses an empty line" do 77 | data = "\n" 78 | temp = Cride::FileHandler.new(data: data) 79 | temp.rows.should eq ["", ""] 80 | temp.to_s.should eq data 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /src/cli.cr: -------------------------------------------------------------------------------- 1 | require "./terminal" 2 | 3 | module Cride::CLI 4 | extend self 5 | 6 | def run(args = ARGV) 7 | args.each do |arg| 8 | case arg 9 | when "--help", "-h" 10 | puts <<-E 11 | cride [files...] 12 | 13 | A light Crystal IDE/editor 14 | E 15 | exit 0 16 | end 17 | open_files ARGV 18 | end 19 | rescue ex 20 | abort ex 21 | end 22 | 23 | private def new_terminal(file_handler : FileHandler) 24 | Terminal.new file_handler: file_handler, color: Terminal::Color.new( 25 | fg: 7, 26 | bg: 234, 27 | bg_info: 0, 28 | bg_line: 236 29 | ) 30 | end 31 | 32 | private def open_files(files) 33 | if !STDIN.tty? 34 | STDIN.read_timeout = 0 35 | new_terminal FileHandler.new STDIN 36 | elsif files.empty? 37 | new_terminal FileHandler.new 38 | else 39 | files.each do |file| 40 | case File 41 | when .directory? file 42 | abort file + " can't be read because it is a directory" 43 | when .exists? file 44 | new_terminal FileHandler.read file 45 | else 46 | new_terminal FileHandler.new(name: file) 47 | end 48 | end 49 | end 50 | end 51 | end 52 | 53 | Cride::CLI.run 54 | -------------------------------------------------------------------------------- /src/editor.cr: -------------------------------------------------------------------------------- 1 | require "./file_handler" 2 | 3 | class Cride::Editor 4 | getter file : FileHandler 5 | property width : Int32 = 0 6 | property height : Int32 = 0 7 | property insert : Bool = false 8 | property tab_spaces : Int32 = 4 # 4 # Must be at least 1 9 | getter cursor_x : Int32 = 0 10 | getter cursor_y : Int32 = 0 11 | getter page_x : Int32 = 0 12 | getter page_y : Int32 = 0 13 | 14 | def initialize(@file : FileHandler) 15 | end 16 | 17 | def additional_tab_width(line : String) : Int32 18 | (@tab_spaces - 1) * line.count('\t') 19 | end 20 | 21 | # Count tabs before the cursor 22 | def additional_tab_spaces_before_absolute_x(line : String) : Int32 23 | spaces = (@tab_spaces - 1) * line[0...absolute_x].count('\t') 24 | end 25 | 26 | {% for pos in %w(absolute_x absolute_y) %} 27 | def {{pos.id}} : Int32 28 | # if {{pos.id}} changes, absolute position changes too 29 | @cursor_{{pos.chars.last.id}} + @page_{{pos.chars.last.id}} 30 | end 31 | {% end %} 32 | 33 | def cursor_x_with_tabs : Int32 34 | row = @file.rows[absolute_y] 35 | @cursor_x + additional_tab_spaces_before_absolute_x row 36 | end 37 | 38 | def reset_x : Nil 39 | @cursor_x = @page_x = 0 40 | end 41 | 42 | def reset_y : Nil 43 | @cursor_y = @page_y = 0 44 | end 45 | end 46 | 47 | require "./editor/*" 48 | -------------------------------------------------------------------------------- /src/editor/add.cr: -------------------------------------------------------------------------------- 1 | class Cride::Editor 2 | def set_char(char : Char) 3 | @file.add.set_char absolute_x, absolute_y, char 4 | move_right 5 | end 6 | 7 | def add_char(char : Char) 8 | @file.add.char absolute_x, absolute_y, char 9 | move_right 10 | end 11 | 12 | def add_line 13 | @file.add.line absolute_x, absolute_y 14 | # Move the cursor down at the begining of the line 15 | reset_x 16 | move_down 17 | end 18 | 19 | def add_duplicated_line 20 | @file.add.duplicate_line absolute_y 21 | move_down 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /src/editor/delete.cr: -------------------------------------------------------------------------------- 1 | class Cride::Editor 2 | def delete_previous_char 3 | case 0 4 | when .< absolute_x 5 | # move left to delete the previous character on the line 6 | move_left 7 | @file.delete.char absolute_x, absolute_y 8 | when .< absolute_y 9 | # go to the previous line 10 | move_left 11 | @file.delete.next_line_append_previous absolute_y 12 | end 13 | end 14 | 15 | def delete_next_char 16 | current_row_size = @file.rows[absolute_y].size 17 | 18 | if absolute_x < current_row_size 19 | # if there are still characters one the line 20 | @file.delete.char absolute_x, absolute_y 21 | elsif absolute_y + 1 < @file.rows.size 22 | # no chars left on the line but still lines next 23 | @file.delete.next_line_append_previous absolute_y 24 | @cursor_x = current_row_size 25 | end 26 | end 27 | 28 | def clear_line 29 | @file.delete.clear_line absolute_y 30 | reset_x 31 | end 32 | 33 | def delete_line 34 | size = @file.rows.size 35 | if size > 1 36 | @file.delete.line absolute_y 37 | move_up if size == absolute_y + 1 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/editor/move.cr: -------------------------------------------------------------------------------- 1 | class Cride::Editor 2 | def move_left 3 | case 0 4 | when .< @cursor_x 5 | # move the cursor left - there are still characters 6 | @cursor_x -= 1 7 | when .< @page_x 8 | # scroll the page to the left if it was previously scrolled 9 | @page_x -= 1 10 | when .< absolute_y 11 | # if there is an upper line go to the end of it 12 | move_up 13 | move_end_of_line 14 | end 15 | end 16 | 17 | def move_right 18 | if absolute_x < @file.rows[absolute_y].size 19 | # move to right if there are characters 20 | if @cursor_x < @width 21 | # still space to move the cursor 22 | @cursor_x += 1 23 | else 24 | # scroll the page to the right 25 | @page_x += 1 26 | end 27 | elsif absolute_y + 1 < @file.rows.size 28 | # else go to the next line if it exists 29 | reset_x 30 | move_down 31 | end 32 | end 33 | 34 | def move_up 35 | case 0 36 | when absolute_y 37 | reset_x 38 | when .< @cursor_y 39 | # move if there is an upper row 40 | @cursor_y -= 1 41 | adapt_end_line 42 | when .< @page_y 43 | # if the page was already scrolled 44 | @page_y -= 1 45 | adapt_end_line 46 | end 47 | end 48 | 49 | def move_down 50 | case absolute_y <=> (rows_size = @file.rows.size - 1) 51 | when 0 52 | # end of the file 53 | @cursor_x = @file.rows[rows_size].size 54 | when -1 55 | # move if there is a row bellow 56 | if @cursor_y >= @height 57 | # scroll the page down if the height limit it reached 58 | @page_y += 1 59 | adapt_end_line 60 | else 61 | # else only move the cursor 62 | @cursor_y += 1 63 | adapt_end_line 64 | end 65 | end 66 | end 67 | 68 | def move_page_up 69 | if absolute_y == 0 70 | reset_x 71 | elsif @page_y > @height 72 | # enough to scroll up 73 | @page_y -= @height 74 | adapt_end_line 75 | else 76 | reset_y 77 | adapt_end_line 78 | end 79 | end 80 | 81 | def move_page_down 82 | case (rows_size = @file.rows.size - 1) 83 | when absolute_y 84 | move_end_of_line 85 | when .> @page_y + @height 86 | # enough to scroll down 87 | @page_y += @height 88 | adapt_end_line 89 | when .> @height 90 | # near the end of file 91 | @page_y = rows_size - @height 92 | @cursor_y = @height 93 | adapt_end_line 94 | else 95 | # at the end of file - only move the cursor 96 | @cursor_y = rows_size 97 | adapt_end_line 98 | end 99 | end 100 | 101 | # Blocks of text are separated by empty rows, or rows including only spaces/tabs. 102 | private def block_traverser(limit) 103 | # If we are in an empty line, go until we reach a non empty line 104 | while (absolute_y != limit) && empty_row?(@file.rows[absolute_y]) 105 | yield 106 | end 107 | # When an non empty line is reached, go to the begining of the block 108 | while (absolute_y != limit) && !empty_row?(@file.rows[absolute_y]) 109 | yield 110 | end 111 | move_down if absolute_y > 0 112 | end 113 | 114 | # Used for CTRL arrow up. 115 | def move_previous_block 116 | move_up 117 | block_traverser(0) { move_up } 118 | end 119 | 120 | # Used for CTRL arrow down. 121 | def move_next_block 122 | block_traverser(@file.rows.size - 1) { move_down } 123 | end 124 | 125 | # An 'empty' row consists of only spaces and/or tabs, or no chars. 126 | def empty_row?(row : String) 127 | row.each_char do |char| 128 | return false if char != ' ' && char != '\t' 129 | end 130 | true 131 | end 132 | 133 | private def word_traverser(diff) 134 | yield 135 | row = @file.rows[absolute_y] 136 | 137 | condition = ->(row : String) { absolute_y != absolute_x != 0 && absolute_x < row.size } 138 | 139 | if (char = row[absolute_x - diff]?) && char.alphanumeric? 140 | # Continue until a non alphanumeric character is found 141 | while condition.call(row) && (char = row[absolute_x - diff]?) && char.alphanumeric? 142 | yield 143 | end 144 | else 145 | # Continue until an alphanumeric character is found 146 | while condition.call(row) && (char = row[absolute_x - diff]?) && !char.alphanumeric? 147 | yield 148 | end 149 | end 150 | end 151 | 152 | # Used for CTRL arrow left. 153 | def move_previous_word 154 | word_traverser(1) { move_left } 155 | end 156 | 157 | # Used for CTRL arrow right. 158 | def move_next_word 159 | word_traverser(0) { move_right } 160 | end 161 | 162 | private def adapt_end_line 163 | if absolute_x > (line_size = @file.rows[absolute_y].size) 164 | # the line if smaller than the one before 165 | case line_size 166 | when 0 167 | # If the line is empty 168 | reset_x 169 | when .< @page_x 170 | # the page size of the former line is longer that the size of the current line - adapt it 171 | @page_x = line_size 172 | @cursor_x = 0 173 | else 174 | # only modify the cursor 175 | @cursor_x = line_size - @page_x 176 | end 177 | end 178 | end 179 | 180 | def move_end_of_line 181 | line_size = @file.rows[absolute_y].size 182 | if line_size > @width 183 | @page_x = line_size - @width 184 | @cursor_x = @width 185 | else 186 | @cursor_x = line_size 187 | end 188 | end 189 | end 190 | -------------------------------------------------------------------------------- /src/file_handler.cr: -------------------------------------------------------------------------------- 1 | class Cride::FileHandler 2 | property rows : Array(String) 3 | # Name or file path on disk. 4 | property name : String? 5 | getter add : Add 6 | getter delete : Delete 7 | 8 | @previous_row_hash : UInt64 9 | 10 | # The data is saved on disk. 11 | def saved? : Bool 12 | @previous_row_hash == @rows.hash 13 | end 14 | 15 | # Reads from a `String`. 16 | def self.new(data : String = "", name : String? = nil, saved : Bool = false) 17 | rows = data.lines 18 | rows << "" 19 | new name, rows, saved 20 | end 21 | 22 | # Read from an `IO`. 23 | def self.new(io : IO, name : String? = nil, saved : Bool = false) 24 | rows = Array(String).new 25 | io.each_line do |line| 26 | rows << line 27 | end 28 | rows << "" 29 | new name, rows, saved 30 | end 31 | 32 | # Reads from a file. 33 | def self.read(file_name : String?, saved : Bool = true) 34 | rows = File.read_lines file_name 35 | rows << "" 36 | new file_name, rows, saved 37 | end 38 | 39 | def initialize(@name : String?, @rows : Array(String), saved : Bool = false) 40 | @previous_row_hash = saved ? rows.hash : 0_u64 41 | @add = Add.new @rows 42 | @delete = Delete.new @rows 43 | end 44 | 45 | def to_s(io : IO) : Nil 46 | @rows.join io, '\n' 47 | end 48 | 49 | # Write the editor's data to a file. 50 | def write 51 | if file_path = @name 52 | File.open file_path, "w" do |io| 53 | to_s io 54 | end 55 | @previous_row_hash = @rows.hash 56 | end 57 | end 58 | end 59 | 60 | require "./file_handler/*" 61 | -------------------------------------------------------------------------------- /src/file_handler/add.cr: -------------------------------------------------------------------------------- 1 | struct Cride::FileHandler::Add 2 | def initialize(@rows : Array(String)) 3 | end 4 | 5 | def char(x : Int32, y : Int32, char : Char) 6 | @rows[y] = @rows[y].insert x, char 7 | end 8 | 9 | def set_char(x : Int32, y : Int32, char : Char) 10 | line = @rows[y] 11 | @rows[y] = if line.size > x 12 | line.sub x, char 13 | else 14 | line + char 15 | end 16 | end 17 | 18 | def line(x : Int32, y : Int32) 19 | old_row = @rows[y] 20 | 21 | # Split the line in two 22 | @rows[y], new_row = old_row[0...x], old_row[x..-1] 23 | 24 | # Append to the new array 25 | @rows.insert y + 1, new_row 26 | end 27 | 28 | def duplicate_line(y : Int32) 29 | @rows.insert y + 1, @rows[y].dup 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /src/file_handler/delete.cr: -------------------------------------------------------------------------------- 1 | struct Cride::FileHandler::Delete 2 | def initialize(@rows : Array(String)) 3 | end 4 | 5 | def char(x : Int32, y : Int32) 6 | @rows[y] = @rows[y].sub(x, "") 7 | end 8 | 9 | # Deletes the line and append the remaing characters to the previous 10 | def next_line_append_previous(y : Int32) 11 | @rows[y] += @rows.delete_at y + 1 12 | end 13 | 14 | def clear_line(y : Int32) 15 | @rows[y] = "" 16 | end 17 | 18 | def line(y : Int32) 19 | @rows.delete_at y 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /src/libc/ioctls.cr: -------------------------------------------------------------------------------- 1 | lib LibC 2 | TIOCGWINSZ = 0x5413 3 | 4 | struct Winsize 5 | ws_row : LibC::Short 6 | ws_col : LibC::Short 7 | ws_xpixel : LibC::Short 8 | ws_ypixel : LibC::Short 9 | end 10 | 11 | fun ioctl(fd : LibC::Int, request : LibC::SizeT, winsize : LibC::Winsize*) : LibC::Int 12 | end 13 | -------------------------------------------------------------------------------- /src/terminal.cr: -------------------------------------------------------------------------------- 1 | require "./editor" 2 | require "./libc/*" 3 | 4 | struct Cride::Terminal 5 | getter color : Color 6 | getter file = File.open "/dev/tty" 7 | 8 | def initialize(file_handler : FileHandler, @color = Color.new) 9 | @io = STDOUT 10 | @editor = Editor.new file_handler 11 | @input = Input.new @file 12 | 13 | # Save cursor, use alternate screen buffer, hide the cursor 14 | print "\033[s", "\033[?1049h", "\033[?25l" 15 | main_loop 16 | end 17 | 18 | # The main editor loop 19 | def main_loop 20 | loop do 21 | LibC.ioctl(1, LibC::TIOCGWINSZ, out screen_size) 22 | @editor.width = screen_size.ws_col.to_i - 1 23 | @editor.height = screen_size.ws_row.to_i - 2 24 | render_editor 25 | @input.read_raw 26 | case @input.type? 27 | when Key::CTRL_C, Key::CTRL_Q, Key::Esc then break 28 | when Key::CTRL_S 29 | begin 30 | @editor.file.write 31 | rescue ex 32 | reset 33 | abort ex 34 | end 35 | when Key::CTRL_D then @editor.add_duplicated_line 36 | when Key::CTRL_K then @editor.file.rows[@editor.absolute_y].empty? ? @editor.delete_line : @editor.clear_line 37 | when Key::CTRL_H, Key::Backspace then @editor.delete_previous_char 38 | when Key::CTRL_ArrowUp then @editor.move_previous_block 39 | when Key::CTRL_ArrowDown then @editor.move_next_block 40 | when Key::CTRL_ArrowRight then @editor.move_next_word 41 | when Key::CTRL_ArrowLeft then @editor.move_previous_word 42 | when Key::ArrowUp then @editor.move_up 43 | when Key::ArrowDown then @editor.move_down 44 | when Key::ArrowRight then @editor.move_right 45 | when Key::ArrowLeft then @editor.move_left 46 | when Key::Delete then @editor.delete_next_char 47 | when Key::PageUp then @editor.move_page_up 48 | when Key::PageDown then @editor.move_page_down 49 | when Key::Enter then @editor.add_line 50 | when Key::Insert then @editor.insert = !@editor.insert 51 | when nil 52 | @input.each_char do |char| 53 | if char == '\r' 54 | @editor.add_line 55 | elsif @editor.insert 56 | @editor.set_char char 57 | else 58 | @editor.add_char char 59 | end 60 | end 61 | end 62 | end 63 | # Essential to call shutdown to reset lower-level terminal flags 64 | rescue ex 65 | reset 66 | abort <<-ERR 67 | An error has occured. Please create an issue at https://github.com/j8r/cride with the steps to how reproduce this bug. 68 | 69 | cursor_y: #{@editor.cursor_y + 1}, cursor_x: #{@editor.cursor_x + 1} 70 | page_y: #{@editor.page_y + 1}, page_x: #{@editor.page_x + 1} 71 | file_rows: #{@editor.file.rows.size}, row_size: #{(row = @editor.file.rows[@editor.absolute_y]?) ? row.size : nil} 72 | Message: "#{ex.message}" 73 | Backtrace: 74 | #{ex.backtrace.join('\n')} 75 | ERR 76 | else 77 | reset 78 | end 79 | 80 | private def reset 81 | # Use normal screen buffer, restore the cursor, show the cursor 82 | print "\033[?1049l", "\033[u", "\033[?25h" 83 | @file.flush 84 | @file.close 85 | end 86 | end 87 | 88 | require "./terminal/*" 89 | -------------------------------------------------------------------------------- /src/terminal/color.cr: -------------------------------------------------------------------------------- 1 | struct Cride::Terminal::Color 2 | getter fg : String, 3 | bg : String, 4 | fg_line : String, 5 | bg_line : String, 6 | fg_info : String, 7 | bg_info : String, 8 | fg_cursor : String, 9 | bg_cursor : String, 10 | fg_unsaved : String, 11 | bg_unsaved : String, 12 | bg_cursor_num : Int32, 13 | reset = "\33[0m" 14 | 15 | def initialize( 16 | fg : Int32 = 15, 17 | bg : Int32 = 0, 18 | fg_line : Int32 = fg, 19 | bg_line : Int32 = bg, 20 | fg_info : Int32 = fg, 21 | bg_info : Int32 = bg, 22 | fg_cursor : Int32 = bg_line, 23 | bg_cursor : Int32 = fg_line, 24 | fg_unsaved : Int32 = 1, 25 | bg_unsaved : Int32 = bg_info 26 | ) 27 | @fg = ansi_foreground fg 28 | @bg = ansi_background bg 29 | @fg_line = ansi_foreground fg_line 30 | @bg_line = ansi_background bg_line 31 | @fg_info = ansi_foreground fg_info 32 | @bg_info = ansi_background bg_info 33 | @fg_cursor = ansi_foreground fg_cursor 34 | @bg_cursor = ansi_background bg_cursor 35 | @fg_unsaved = ansi_foreground fg_unsaved 36 | @bg_unsaved = ansi_background bg_unsaved 37 | @bg_cursor_num = bg_cursor 38 | end 39 | 40 | def ansi_foreground(color, mode = Mode::Normal) 41 | "\033[#{mode.value};38;5;#{color}m" 42 | end 43 | 44 | def ansi_background(color, mode = Mode::Normal) 45 | "\033[2;48;5;#{color}m" 46 | end 47 | 48 | enum Mode 49 | Normal 50 | Bold 51 | Faint 52 | Italic 53 | Underline 54 | SlowBlink 55 | RapidBlink 56 | Reverse 57 | Conceal 58 | CrossedOut 59 | Default 60 | end 61 | 62 | enum Select 63 | Default 64 | Black 65 | Red 66 | Green 67 | Yellow 68 | Blue 69 | Magenta 70 | Cyan 71 | White 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /src/terminal/input.cr: -------------------------------------------------------------------------------- 1 | struct Cride::Terminal 2 | struct Input 3 | getter file : File 4 | 5 | @buffer : Bytes = Bytes.new 512 6 | @read_count = 0 7 | 8 | def initialize(@file : File) 9 | end 10 | 11 | # Read the raw input of the IO. 12 | def read_raw : Nil 13 | @read_count = @file.raw &.read @buffer 14 | end 15 | 16 | def type? : Key? 17 | if control? 18 | value = 0_u8 19 | @read_count.times do |i| 20 | value &+= @buffer[i] 21 | end 22 | Key.new value 23 | end 24 | end 25 | 26 | def each_char(& : Char ->) 27 | String.new(@buffer[0, @read_count]).each_char do |char| 28 | yield char 29 | end 30 | end 31 | 32 | def control? 33 | case @buffer[0].unsafe_chr 34 | when '\t', '\r' then false 35 | when .control? then true 36 | else false 37 | end 38 | end 39 | end 40 | 41 | enum Key : UInt8 42 | Tilde = 0 43 | CTRL_2 = 0 44 | CTRL_A 45 | CTRL_B 46 | CTRL_C 47 | CTRL_D 48 | CTRL_E 49 | CTRL_F 50 | CTRL_G 51 | Backspace0 52 | CTRL_H = 8 53 | Tab 54 | CTRL_I = 9 55 | LineField 56 | CTRL_J = 10 57 | CTRL_K 58 | CTRL_L 59 | Enter 60 | CarriageReturn = 13 61 | CTRL_M = 13 62 | CTRL_N 63 | CTRL_O 64 | CTRL_P 65 | CTRL_Q 66 | CTRL_R 67 | CTRL_S 68 | CTRL_T 69 | CTRL_U 70 | CTRL_V 71 | CTRL_W 72 | CTRL_X 73 | CTRL_Y 74 | CTRL_Z 75 | Esc 76 | CTRL_LSQ_BRACKET = 27 77 | CTRL_3 = 27 78 | CTRL_4 79 | CTRL_Backslash = 28 80 | CTRL_5 81 | CTRL_RSQ_BRACKET = 29 82 | CTRL_6 = 30 83 | CTRL_7 84 | CTRL_Slash = 31 85 | CTRL_Underscore = 31 86 | Space 87 | Exclamation 88 | QuotationMarks 89 | Hash 90 | Dollar 91 | Percent 92 | Insert 93 | Delete 94 | Home 95 | PageUp 96 | PageDown 97 | CTRL_1 = 49 98 | CTRL_9 = 57 99 | F9 = 86 100 | F10 101 | CTRL_ArrowUp 102 | CTRL_ArrowDown 103 | CTRL_ArrowRight 104 | CTRL_ArrowLeft 105 | F6 106 | F7 107 | F8 108 | Backspace = 127 109 | CTRL_8 = 127 110 | ArrowUp = 183 111 | ArrowDown 112 | ArrowRight 113 | ArrowLeft 114 | end 115 | 116 | enum InputMode : UInt8 117 | Current 118 | Esc 119 | Alt 120 | Mouse 121 | end 122 | 123 | enum Output : UInt8 124 | Current 125 | Normal 126 | C_256 127 | C_216 128 | Grayscale 129 | end 130 | 131 | @[Flags] 132 | enum Event : UInt8 133 | Key = 1 134 | Resize 135 | Mouse 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /src/terminal/render.cr: -------------------------------------------------------------------------------- 1 | struct Cride::Terminal 2 | @io : IO 3 | 4 | private def cell_color(x, y) 5 | if y == @editor.cursor_y 6 | # highlight the selected line 7 | File.write "debug", "#{x}|#{@editor.cursor_x_with_tabs}" 8 | if x == @editor.cursor_x_with_tabs 9 | # cursor position 10 | if @editor.insert 11 | @io << @color.ansi_foreground(@color.bg_cursor_num, Color::Mode::Underline) << @color.bg_line 12 | else 13 | @io << @color.fg_cursor << @color.bg_cursor 14 | end 15 | else 16 | @io << @color.fg_line << @color.bg_line 17 | end 18 | else 19 | @io << @color.fg << @color.bg 20 | end 21 | end 22 | 23 | def render_editor 24 | # Set the cursor at home (on the top left) 25 | @io << "\033[H" 26 | y = 0 27 | 28 | # Render starting at the page_y line until the end of the terminal height 29 | @editor.file.rows[@editor.page_y..@editor.page_y + @editor.height].each do |row| 30 | x = 0 31 | width = @editor.width + @editor.additional_tab_width row 32 | # Start to render at the page_x until the end of the terminal width 33 | if row[@editor.page_x]? 34 | row[@editor.page_x..@editor.page_x + width].each_char do |char| 35 | # highlight current line 36 | if char == '\t' 37 | @editor.tab_spaces.times do 38 | cell_color x, y 39 | @io << ' ' 40 | x += 1 41 | end 42 | else 43 | cell_color x, y 44 | @io << char 45 | x += 1 46 | end 47 | end 48 | end 49 | if x == @editor.cursor_x_with_tabs && y == @editor.cursor_y 50 | cell_color x, y 51 | @io << ' ' 52 | x += 1 53 | end 54 | cell_color x, y 55 | fill_line x 56 | @io << '\n' 57 | y += 1 58 | end 59 | # Fill remaining empty rows 60 | (@editor.height + 1 - y).times do 61 | @io << @color.fg << @color.bg 62 | fill_line 0 63 | @io << '\n' 64 | end 65 | 66 | # Add the lower info line 67 | render_info 68 | @io.flush 69 | end 70 | 71 | def render_info 72 | row = @editor.file.rows[@editor.absolute_y] 73 | position = String.build do |str| 74 | str << " y:" 75 | str << @editor.absolute_y + 1 76 | str << '/' 77 | str << @editor.file.rows.size 78 | str << " x:" 79 | str << @editor.absolute_x + 1 + @editor.additional_tab_spaces_before_absolute_x row 80 | str << '/' 81 | str << row.size + 1 + @editor.additional_tab_width row 82 | end 83 | # Return a colored info line 84 | if @editor.file.saved? 85 | @io << @color.bg_info << @color.fg_info 86 | else 87 | @io << @color.bg_unsaved << @color.fg_unsaved 88 | end 89 | @io << @editor.file.name 90 | @io << @color.bg_info << @color.fg_info 91 | @io << position 92 | fill_line (@editor.file.name.to_s + position).size 93 | end 94 | 95 | # Fill remainig cells with spaces 96 | private def fill_line(line_size) 97 | (@editor.width + 1 - line_size).times do 98 | @io << ' ' 99 | end 100 | end 101 | end 102 | --------------------------------------------------------------------------------