From 23e57a65cc8d7adb62d61eff3f0b4ac0c2e7ca1b Mon Sep 17 00:00:00 2001 From: Largo Date: Thu, 1 Oct 2026 21:25:37 +0200 Subject: [PATCH] Samples/Notepad: Windows Notepad in Ruby, on Windows Forms, with a test CI runs A Notepad for Windows in one Ruby file, on IronRuby and Windows Forms: New, New Window, Open, Save, Save As, Page Setup and Print (header, page numbers); Undo and Redo, Cut, Copy, Paste, Delete, Find, Find Next/Previous, Replace, Replace All, Go To, Select All, Time/Date; Word Wrap, Font, Zoom (Ctrl+wheel too), and the status bar with Ln/Col, characters, zoom, line endings and encoding. Files open as UTF-8, UTF-8 with BOM, UTF-16 LE/BE or ANSI and are saved the way they came, line endings included. It asks before throwing away changes, opens dropped files, and remembers font, wrap, status bar and window. The text box is RichEdit in plain-text mode (EM_SETTEXTMODE, sent through Fiddle), as Notepad's is: no formatting gets in by pasting, undo has many levels, and the Ctrl shortcuts RichEdit would turn into formatting are swallowed. Tabs are 8 characters (EM_SETTABSTOPS, again after a font change). test_notepad.rb drives it with nobody at the keyboard: every encoding through open and save, byte for byte; editing, find, replace, wrap, Ln/Col, zoom, tab stops, the formatting keys; printing to PDF where that printer exists; and the modal dialogs, opened and closed by a timer. CI runs it on Windows and keeps a picture of each window (NOTEPAD_SCREENSHOTS). --- .github/workflows/ci.yml | 17 + README.md | 2 + Samples/Notepad/notepad.cmd | 16 + Samples/Notepad/notepad.rb | 1365 +++++++++++++++++++++++++++++++ Samples/Notepad/test_notepad.rb | 295 +++++++ 5 files changed, 1695 insertions(+) create mode 100644 Samples/Notepad/notepad.cmd create mode 100644 Samples/Notepad/notepad.rb create mode 100644 Samples/Notepad/test_notepad.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01cb9b72..9a9b4e78 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -180,6 +180,23 @@ jobs: ./ir.sh smoke.rb fi + # Samples/Notepad, a Windows Forms application, driven by its own test with nobody + # at the keyboard. A dialog that never closes would wait forever, hence the limit. + - name: Notepad sample + if: runner.os == 'Windows' + timeout-minutes: 5 + working-directory: ironruby + run: NOTEPAD_SCREENSHOTS="$RUNNER_TEMP/notepad" cmd //c ir.cmd Samples/Notepad/test_notepad.rb + + # What each window looked like, to look at a failure without a Windows machine. + - name: Keep the Notepad screenshots + if: always() && runner.os == 'Windows' + uses: actions/upload-artifact@v7 + with: + name: notepad-screenshots-${{ matrix.tfm }} + path: ${{ runner.temp }}/notepad + if-no-files-found: ignore + # irubyc --single-file: one executable carrying IronRuby, libprism and the stdlib. # Run from an empty directory, so it can only work if everything really is inside it. - name: irubyc single file diff --git a/README.md b/README.md index edb4758a..abf60845 100644 --- a/README.md +++ b/README.md @@ -374,6 +374,8 @@ readline are not implemented on Windows; `IO.console` is nil, so irb falls back input path. Windows Forms and WPF (`require "System.Windows.Forms"`, `"PresentationFramework"`) come from the Windows Desktop runtime, which `ir` references whenever it is built for Windows (`-p:IronRubyWindowsDesktop=false` leaves it out); on Linux and macOS they do not exist. +[`Samples/Notepad`](Samples/Notepad/notepad.rb) is Windows Notepad written that way, in Ruby +(`Samples\Notepad\notepad.cmd`); its test drives it with nobody at the keyboard and runs in CI. macOS is untested. ## Binary releases diff --git a/Samples/Notepad/notepad.cmd b/Samples/Notepad/notepad.cmd new file mode 100644 index 00000000..42f51546 --- /dev/null +++ b/Samples/Notepad/notepad.cmd @@ -0,0 +1,16 @@ +@echo off +rem IronRuby Notepad. Double-click it, drop a file on it, or run: notepad.cmd file.txt +rem +rem ir.exe is a console program, so Windows gives it a console window. "start /b" leaves +rem that console to ir.exe alone once this batch file has ended, and notepad.rb then +rem lets go of it, which closes the window. To keep the console for Ruby's output, run +rem ..\..\ir.cmd notepad.rb instead. The build is chosen as ir.cmd chooses it. +setlocal +set "IR_ROOT=%~dp0..\.." +if "%IR_CONFIG%"=="" if not exist "%IR_ROOT%\Src\Console\bin\Debug\" if exist "%IR_ROOT%\Src\Console\bin\Release\" set "IR_CONFIG=Release" +if "%IR_CONFIG%"=="" set "IR_CONFIG=Debug" +if "%IR_TFM%"=="" set "IR_TFM=net8.0" +set "IR_BIN=%IR_ROOT%\Src\Console\bin\%IR_CONFIG%\%IR_TFM%\win-x64\ir.exe" +if not exist "%IR_BIN%" set "IR_BIN=%IR_ROOT%\Src\Console\bin\%IR_CONFIG%\%IR_TFM%\ir.exe" +set "S=%IR_ROOT%\Src\StdLib" +start "" /b "%IR_BIN%" -X:UsePrism "-X:StdLib=%S%\ironruby;%S%\ruby\4.0;%S%\ruby\1.9.1" "%~dp0notepad.rb" %* diff --git a/Samples/Notepad/notepad.rb b/Samples/Notepad/notepad.rb new file mode 100644 index 00000000..2a3b3b20 --- /dev/null +++ b/Samples/Notepad/notepad.rb @@ -0,0 +1,1365 @@ +# IronRuby Notepad: Windows Notepad, written in Ruby, on IronRuby and Windows Forms. +# +# notepad.cmd [file] double-click it, drop a file on it, or run it +# ..\..\ir.cmd notepad.rb [file] the same, keeping the console for Ruby's output +# +# What Notepad does, this does: New, New Window, Open, Save, Save As, Page Setup and +# Print with a header and page numbers; Undo and Redo, Cut, Copy, Paste, Delete, Find, +# Find Next and Previous, Replace and Replace All, Go To, Select All, Time/Date; Word +# Wrap, Font, Zoom (also Ctrl+wheel), the status bar with line, column, characters, +# zoom, line endings and encoding. Files open as UTF-8, UTF-8 with BOM, UTF-16 LE/BE or +# ANSI and are saved the way they came, line endings included; both can be changed +# from the status bar. It asks before throwing away changes, opens files dropped on +# it, and remembers font, word wrap, status bar and window position. +# +# The text box is a RichEdit control switched to plain-text mode, as Notepad's is: no +# formatting can get in by pasting or by RichEdit's own formatting shortcuts, and undo +# has many levels. +require "System.Windows.Forms" +require "System.Drawing" +require "System.Text.Encoding.CodePages, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" +require "fiddle" +require "json" + +module IronRubyNotepad + F = System::Windows::Forms + D = System::Drawing + K = F::Keys + APP_NAME = "IronRuby Notepad" + FILTER = "Text Documents (*.txt)|*.txt|All Files (*.*)|*.*" + + # The few Win32 calls Windows Forms has no wrapper for. + module Native + user32 = Fiddle.dlopen("user32") + kernel32 = Fiddle.dlopen("kernel32") + ptr = Fiddle::TYPE_VOIDP + SEND_MESSAGE = Fiddle::Function.new(user32["SendMessageW"], + [ptr, Fiddle::TYPE_UINT, Fiddle::TYPE_UINTPTR_T, ptr], Fiddle::TYPE_INTPTR_T) + GET_CARET_POS = Fiddle::Function.new(user32["GetCaretPos"], [ptr], Fiddle::TYPE_INT) + CONSOLE_PROCESSES = Fiddle::Function.new(kernel32["GetConsoleProcessList"], [ptr, Fiddle::TYPE_UINT], Fiddle::TYPE_UINT) + FREE_CONSOLE = Fiddle::Function.new(kernel32["FreeConsole"], [], Fiddle::TYPE_INT) + + # lparam is an Integer or a Fiddle::Pointer. + def self.send_message(control, message, wparam = 0, lparam = 0) + SEND_MESSAGE.call(control.handle.to_int64, message, wparam, lparam) + end + + def self.caret_position + point = Fiddle::Pointer.malloc(8) + return nil if GET_CARET_POS.call(point).zero? + D::Point.new(*point[0, 8].unpack("l2")) + end + + # ir.exe is a console program, so started from Explorer it gets a console window of + # its own. When nobody else is attached to it, let go of it: that closes the window. + def self.detach_console_if_alone + list = Fiddle::Pointer.malloc(64) + return unless CONSOLE_PROCESSES.call(list, 16) == 1 + FREE_CONSOLE.call + $stdout = $stderr = File.open("NUL", "w") + end + end + + # Reading and writing text files the way Notepad does: the encoding is taken from the + # byte order mark, else UTF-8 if the bytes are valid UTF-8, else the ANSI code page; + # the line ending from the first line break. Saving writes both back unchanged. + module TextFile + ENCODINGS = { utf8: "UTF-8", utf8_bom: "UTF-8 with BOM", utf16le: "UTF-16 LE", + utf16be: "UTF-16 BE", ansi: "ANSI" }.freeze + LINE_ENDINGS = { crlf: "Windows (CRLF)", lf: "Unix (LF)", cr: "Macintosh (CR)" }.freeze + BREAKS = { crlf: "\r\n", lf: "\n", cr: "\r" }.freeze + + System::Text::Encoding.register_provider(System::Text::CodePagesEncodingProvider.instance) + + def self.encoding(kind) + case kind + when :utf8 then System::Text::UTF8Encoding.new(false) + when :utf8_bom then System::Text::UTF8Encoding.new(true) + when :utf16le then System::Text::UnicodeEncoding.new(false, true) + when :utf16be then System::Text::UnicodeEncoding.new(true, true) + else System::Text::Encoding.get_encoding(0) # the system's ANSI code page + end + end + + # => [text, encoding, line ending]; the text has "\n" for every line break. + def self.read(path) + bytes = System::IO::File.read_all_bytes(path) + kind, skip = byte_order_mark(bytes) + text = + if kind + encoding(kind).get_string(bytes, skip, bytes.length - skip) + else + begin + kind = :utf8 + System::Text::UTF8Encoding.new(false, true).get_string(bytes) + rescue System::Text::DecoderFallbackException, System::ArgumentException + kind = :ansi + encoding(:ansi).get_string(bytes) + end + end + text = text.to_s + [text.gsub(/\r\n?/, "\n"), kind, line_ending(text)] + end + + def self.byte_order_mark(b) + n = b.length + return [:utf8_bom, 3] if n >= 3 && b[0] == 0xEF && b[1] == 0xBB && b[2] == 0xBF + return [:utf16le, 2] if n >= 2 && b[0] == 0xFF && b[1] == 0xFE + return [:utf16be, 2] if n >= 2 && b[0] == 0xFE && b[1] == 0xFF + [nil, 0] + end + + def self.line_ending(text) + i = text.index(/[\r\n]/) + return :crlf unless i + return :lf if text[i] == "\n" + text[i + 1] == "\n" ? :crlf : :cr + end + + def self.encode(text, kind, eol) + enc = encoding(kind) + body = enc.get_bytes(text.to_s.gsub("\n", BREAKS[eol]).to_clr_string) + preamble = enc.get_preamble + all = System::Array[System::Byte].new(preamble.length + body.length) + preamble.copy_to(all, 0) + body.copy_to(all, preamble.length) + all + end + + def self.write(path, text, kind, eol) + System::IO::File.write_all_bytes(path, encode(text, kind, eol)) + end + + # Would saving in this encoding lose characters (ANSI cannot hold most of Unicode)? + def self.lossless?(text, kind) + enc = encoding(kind) + s = text.to_s.to_clr_string + enc.get_string(enc.get_bytes(s)).equals(s) + end + end + + class Settings + DEFAULTS = { + "font_family" => "Consolas", "font_size" => 11.0, "bold" => false, "italic" => false, + "word_wrap" => false, "status_bar" => true, "match_case" => false, "wrap_around" => false, + "window" => nil, "maximized" => false + }.freeze + + def self.default_path + File.join(ENV["APPDATA"] || Dir.home, APP_NAME, "settings.json") + end + + def initialize(path = Settings.default_path) + @path = path + @values = DEFAULTS.merge(read) + end + + def [](key) = @values[key] + + def []=(key, value) + @values[key] = value + end + + def save + System::IO::Directory.create_directory(File.dirname(@path)) + File.write(@path, JSON.pretty_generate(@values)) + rescue Exception # a setting that does not stick is not worth an error box + nil + end + + private + + def read + File.exist?(@path) ? JSON.parse(File.read(@path)) : {} + rescue Exception + {} + end + end + + # The text area: a RichTextBox in plain-text mode. + class Editor + EM_SETTEXTMODE = 0x0459 + TM_PLAINTEXT = 1 + TM_MULTILEVELUNDO = 8 + TM_MULTICODEPAGE = 32 + EM_SETUNDOLIMIT = 0x0452 + EM_SETTARGETDEVICE = 0x0448 + EM_SETTABSTOPS = 0x00CB + EM_SETMARGINS = 0x00D3 + EM_SETZOOM = 0x04E1 + + # Ctrl with a letter, digit or punctuation key is formatting in RichEdit - Ctrl+E + # centres, Ctrl+1 sets line spacing, Ctrl+Shift+> grows the font - and in plain-text + # mode it formats the whole document. The ones Notepad uses are menu shortcuts and + # never get here. Ctrl+Alt is AltGr, which types characters, so it passes. + FORMATTING_KEYS = [*("A".."Z").map { K.send(it) }, *(0..9).map { K.send("D#{it}") }, + K.Oemcomma, K.OemPeriod, K.Oemplus, K.OemMinus, K.OemQuestion, K.Oemtilde, + K.OemOpenBrackets, K.OemCloseBrackets, K.OemPipe, K.OemSemicolon, K.OemQuotes, + K.OemBackslash].freeze + + attr_reader :box, :zoom, :wrap + attr_accessor :on_zoom, :on_redo + + def initialize + @box = F::RichTextBox.new + @box.border_style = F::BorderStyle.None + @box.dock = F::DockStyle.Fill + @box.detect_urls = false + @box.accepts_tab = true + @box.hide_selection = false # a match stays visible while the Find dialog has the focus + @box.word_wrap = false # wrapping goes through EM_SETTARGETDEVICE, see apply_view + @box.scroll_bars = F::RichTextBoxScrollBars.Both + @box.allow_drop = true + @wrap = false + @zoom = 1.0 + @box.handle_created { configure_native } + @box.text_changed { @text = nil } + @box.key_down { |_, e| key_down(e) } + @box.mouse_wheel { |_, e| mouse_wheel(e) } + end + + def text = @box.text + def length = @box.text_length + def modified = @box.modified + + def modified=(value) + @box.modified = value + end + + def font = @box.font + + def font=(font) + @box.font = font + apply_view # tab stops and margins are measured in the font + end + + def wrap=(on) + @wrap = on + apply_view + end + + def zoom=(factor) + @zoom = factor + apply_view + end + + def load_text(text) + @box.text = text + @box.clear_undo + @box.select(0, 0) + @box.modified = false + @text = nil + end + + def insert(text) + @box.selected_text = text + end + + def delete + if @box.selection_length.zero? + return if @box.selection_start >= @box.text_length + @box.selection_length = 1 + end + @box.selected_text = "" + end + + # Ln and Col as Notepad counts them: logical lines, whether wrapped or not, at the + # end of the selection the caret is on. + def line_and_column + pos = caret_index + if @wrap + before = ruby_text[0, pos] + [before.count("\n") + 1, pos - ((before.rindex("\n") || -1) + 1) + 1] + else + line = @box.get_line_from_char_index(pos) + [line + 1, pos - @box.get_first_char_index_from_line(line) + 1] + end + end + + def line_count + @wrap ? ruby_text.count("\n") + 1 : @box.get_line_from_char_index(length) + 1 + end + + def go_to_line(line) + start = 0 + if @wrap + (line - 1).times { start = ruby_text.index("\n", start) + 1 } + else + start = @box.get_first_char_index_from_line(line - 1) + end + @box.select(start, 0) + @box.scroll_to_caret + end + + # Selects the next match and returns true, or returns false. + def find(what, match_case:, up:, wrap:) + return false if what.empty? + options = match_case ? F::RichTextBoxFinds.MatchCase : F::RichTextBoxFinds.None + back = options | F::RichTextBoxFinds.Reverse + len = length + from = @box.selection_start + to = from + @box.selection_length + found = + if up + at = from.positive? ? @box.find(what, 0, from, back) : -1 + at.negative? && wrap ? @box.find(what, 0, len, back) : at + else + at = to < len ? @box.find(what, to, len, options) : -1 + at.negative? && wrap ? @box.find(what, 0, len, options) : at + end + return false if found.negative? + @box.scroll_to_caret + true + end + + def replace_selection(what, with, match_case) + selected = @box.selected_text.to_s + return if selected.empty? + same = match_case ? selected == what : selected.downcase == what.downcase + @box.selected_text = with if same + end + + # One undo step for all of them. Returns how many there were. + def replace_all(what, with, match_case) + return 0 if what.empty? + pattern = Regexp.new(Regexp.escape(what), match_case ? 0 : Regexp::IGNORECASE) + count = 0 + result = ruby_text.gsub(pattern) do + count += 1 + with + end + return 0 if count.zero? + caret = @box.selection_start + @box.select_all + @box.selected_text = result + @box.select([caret, length].min, 0) + count + end + + def key_down(e) + return unless e.control && !e.alt + code = e.key_code + case code + when K.Add then zoom_key(e, :in) + when K.Subtract then zoom_key(e, :out) + when K.NumPad0 then zoom_key(e, :reset) + else + if code == K.Z && e.shift + e.suppress_key_press = true + @on_redo&.call + elsif FORMATTING_KEYS.include?(code) + e.suppress_key_press = true + end + end + end + + # Wrapping, tab stops, margins and zoom live in the native control. Windows Forms + # sets wrapping again while it creates the handle, so the window calls this once + # more when it has loaded. + def apply_view + return unless @box.is_handle_created + message(EM_SETTARGETDEVICE, 0, @wrap ? 0 : 1) + stops = Fiddle::Pointer.malloc(4) + stops[0, 4] = [32].pack("l") # dialog units: 8 characters, as in Notepad + message(EM_SETTABSTOPS, 1, stops) + margin = (4 * @box.device_dpi / 96.0).round + message(EM_SETMARGINS, 3, margin | (margin << 16)) + percent = (@zoom * 100).round + percent == 100 ? message(EM_SETZOOM, 0, 0) : message(EM_SETZOOM, percent, 100) + end + + private + + def ruby_text + @text ||= @box.text.to_s + end + + # The caret sits at one end of the selection, and RichEdit only tells which end + # through where it drew the caret. + def caret_index + start = @box.selection_start + len = @box.selection_length + return start if len.zero? + return start + len unless @box.focused # a match Find selected: the caret is at its end + caret = Native.caret_position + return start + len unless caret + at_start = @box.get_position_from_char_index(start) + (caret.x - at_start.x).abs <= 2 && (caret.y - at_start.y).abs <= 2 ? start : start + len + end + + def message(msg, wparam = 0, lparam = 0) = Native.send_message(@box, msg, wparam, lparam) + + # RichEdit takes plain-text mode only while it is empty, so right after the handle + # exists. Should text be there already (a recreated handle), it goes out and back in. + def configure_native + text = nil + if @box.text_length.positive? + text = @box.text + modified = @box.modified + @box.text = "" + end + message(EM_SETTEXTMODE, TM_PLAINTEXT | TM_MULTILEVELUNDO | TM_MULTICODEPAGE) + message(EM_SETUNDOLIMIT, 10_000) + if text + @box.text = text + @box.modified = modified + end + apply_view + end + + def zoom_key(e, how) + e.suppress_key_press = true + @on_zoom&.call(how) + end + + # Ctrl+wheel zooms in steps of 10 %, like Notepad, instead of RichEdit's own steps. + def mouse_wheel(e) + return unless F::Control.modifier_keys == K.Control + e.handled = true + @on_zoom&.call(e.delta.positive? ? :in : :out) + end + end + + # Layout pieces shared by the dialogs. Sizes are in pixels at 96 DPI; scale_to_screen + # turns them into the screen's, and everything else sizes itself from the font. + module Dialog + def self.style(form, title) + form.text = title + form.form_border_style = F::FormBorderStyle.FixedDialog + form.maximize_box = false + form.minimize_box = false + form.show_in_taskbar = false + form.show_icon = false + form.auto_size = true + form.auto_size_mode = F::AutoSizeMode.GrowAndShrink + form.padding = F::Padding.new(8) + end + + # Once the controls are in: set on an empty form, AutoScaleMode scales the form at + # once, finds no controls, and later ones keep their 96-DPI sizes. + def self.scale_to_screen(form) + form.suspend_layout + form.auto_scale_dimensions = D::SizeF.new(96, 96) + form.auto_scale_mode = F::AutoScaleMode.Dpi + form.resume_layout(false) + form.perform_layout + end + + def self.label(text) + label = F::Label.new + label.text = text + label.auto_size = true + label.anchor = F::AnchorStyles.Left + label + end + + def self.text_box(width = 230) + box = F::TextBox.new + box.width = width + box.anchor = F::AnchorStyles.Left | F::AnchorStyles.Right + box + end + + def self.button(text, &click) + button = F::Button.new + button.text = text + button.auto_size = true + button.auto_size_mode = F::AutoSizeMode.GrowAndShrink # GrowOnly would keep the scaled-up height + button.minimum_size = D::Size.new(96, 0) + button.click { click.call } if click + button + end + + def self.check_box(text) + box = F::CheckBox.new + box.text = text + box.auto_size = true + box + end + + def self.flow(direction) + panel = F::FlowLayoutPanel.new + panel.flow_direction = direction + panel.wrap_contents = false + panel.auto_size = true + panel.auto_size_mode = F::AutoSizeMode.GrowAndShrink + panel.margin = F::Padding.new(0) + panel + end + + def self.table(columns) + table = F::TableLayoutPanel.new + table.auto_size = true + table.auto_size_mode = F::AutoSizeMode.GrowAndShrink + table.column_count = columns + columns.times { table.column_styles.add(F::ColumnStyle.new(F::SizeType.AutoSize)) } + table + end + end + + # Find and Replace: modeless, owned by the window, hidden rather than closed so that + # they keep what was typed. + class FindDialog + attr_reader :form + + def initialize(window, replace:) + @window = window + @replace = replace + @search = window.search + build + end + + def show + selected = @window.editor.box.selected_text.to_s + @what.text = selected.empty? || selected.include?("\n") ? @search[:text] : selected + @with.text = @search[:replace] if @replace + @match_case.checked = @search[:match_case] + @wrap.checked = @search[:wrap] + @up.checked = @search[:up] unless @replace + @down.checked = !@search[:up] unless @replace + if @form.visible + @form.activate + else + @form.show(@window.form) + end + @what.select_all + @what.focus + end + + def hide + @form.hide + @window.form.activate + end + + private + + def build + @form = F::Form.new + Dialog.style(@form, @replace ? "Replace" : "Find") + @form.start_position = F::FormStartPosition.Manual + table = Dialog.table(3) + @what = Dialog.text_box + table.controls.add(Dialog.label("Fi&nd what:"), 0, 0) + table.controls.add(@what, 1, 0) + row = 1 + if @replace + @with = Dialog.text_box + table.controls.add(Dialog.label("Re&place with:"), 0, row) + table.controls.add(@with, 1, row) + row += 1 + end + options = Dialog.flow(F::FlowDirection.TopDown) + @match_case = Dialog.check_box("Match &case") + @wrap = Dialog.check_box("Wrap ar&ound") + options.controls.add(@match_case) + options.controls.add(@wrap) + bottom = Dialog.flow(F::FlowDirection.LeftToRight) + bottom.margin = F::Padding.new(0, 6, 0, 0) + bottom.controls.add(options) + bottom.controls.add(direction_box) unless @replace + table.controls.add(bottom, 0, row) + table.set_column_span(bottom, 2) + + buttons = Dialog.flow(F::FlowDirection.TopDown) + buttons.margin = F::Padding.new(8, 0, 0, 0) + @find_next = Dialog.button("&Find Next") { find_next } + buttons.controls.add(@find_next) + if @replace + @replace_button = Dialog.button("&Replace") { replace } + @replace_all = Dialog.button("Replace &All") { replace_all } + buttons.controls.add(@replace_button) + buttons.controls.add(@replace_all) + end + cancel = Dialog.button("Cancel") { hide } + buttons.controls.add(cancel) + table.controls.add(buttons, 2, 0) + table.set_row_span(buttons, row + 1) + + @form.controls.add(table) + @form.accept_button = @find_next + @form.cancel_button = cancel + Dialog.scale_to_screen(@form) + wire + end + + def direction_box + group = F::GroupBox.new + group.text = "Direction" + group.auto_size = true + group.auto_size_mode = F::AutoSizeMode.GrowAndShrink + group.margin = F::Padding.new(12, 0, 0, 0) + radios = Dialog.flow(F::FlowDirection.LeftToRight) + radios.dock = F::DockStyle.Fill + @up = F::RadioButton.new + @up.text = "&Up" + @up.auto_size = true + @down = F::RadioButton.new + @down.text = "&Down" + @down.auto_size = true + radios.controls.add(@up) + radios.controls.add(@down) + group.controls.add(radios) + group + end + + def wire + @what.text_changed do + @search[:text] = @what.text.to_s + enable_buttons + end + @with&.text_changed { @search[:replace] = @with.text.to_s } + @match_case.checked_changed { @search[:match_case] = @match_case.checked } + @wrap.checked_changed { @search[:wrap] = @wrap.checked } + @up&.checked_changed { @search[:up] = @up.checked } + @form.form_closing do |_, e| + if e.close_reason == F::CloseReason.UserClosing + e.cancel = true + hide + end + end + @form.Load { center_on_owner } + end + + def enable_buttons + on = !@what.text.to_s.empty? + [@find_next, @replace_button, @replace_all].compact.each { it.enabled = on } + end + + def center_on_owner + owner = @window.form.bounds + @form.location = D::Point.new(owner.x + (owner.width - @form.width) / 2, + owner.y + (owner.height - @form.height) / 3) + end + + def find_next = @window.find_next(up: @replace ? false : @up.checked, owner: @form) + + def replace = @window.replace_next(owner: @form) + + def replace_all = @window.replace_all(owner: @form) + end + + module GoToDialog + # => the line number, or nil. + def self.ask(owner, current, last) + form = F::Form.new + Dialog.style(form, "Go To Line") + form.start_position = F::FormStartPosition.CenterParent + table = Dialog.table(1) + table.controls.add(Dialog.label("&Line number:"), 0, 0) + box = Dialog.text_box(260) + box.text = current.to_s + box.key_press { |_, e| e.handled = !(System::Char.is_digit(e.key_char) || System::Char.is_control(e.key_char)) } + table.controls.add(box, 0, 1) + buttons = Dialog.flow(F::FlowDirection.RightToLeft) + buttons.anchor = F::AnchorStyles.Right + buttons.margin = F::Padding.new(0, 8, 0, 0) + line = nil + cancel = Dialog.button("Cancel") + cancel.dialog_result = F::DialogResult.Cancel + ok = Dialog.button("Go To") do + n = box.text.to_s.match?(/\A\d+\z/) ? box.text.to_s.to_i : nil + if n && n.between?(1, last) + line = n + form.dialog_result = F::DialogResult.OK + else + F::MessageBox.show(form, "The line number is beyond the total number of lines", + "#{APP_NAME} - Goto Line", F::MessageBoxButtons.OK, F::MessageBoxIcon.None) + box.select_all + end + end + buttons.controls.add(cancel) + buttons.controls.add(ok) + table.controls.add(buttons, 0, 2) + form.controls.add(table) + form.accept_button = ok + form.cancel_button = cancel + Dialog.scale_to_screen(form) + box.select_all + form.show_dialog(owner) + form.dispose + line + end + end + + # Page setup and printing: the editor's font, wrapped to the page, with the file name + # as the header and "Page n" as the footer, as Notepad prints. + class Printer + def printer_settings + @printer_settings ||= D::Printing::PrinterSettings.new + end + + def page_settings + @page_settings ||= D::Printing::PageSettings.new(printer_settings).tap do |page| + page.margins = D::Printing::Margins.new(79, 79, 98, 98) # Notepad's 20 and 25 mm + end + end + + def page_setup(owner) + dialog = F::PageSetupDialog.new + dialog.printer_settings = printer_settings + dialog.page_settings = page_settings + dialog.enable_metric = true + dialog.show_dialog(owner) + end + + # dialog: false prints on the current printer without asking. + def print_text(owner, text, font, title, dialog: true) + document = D::Printing::PrintDocument.new + document.document_name = title + document.printer_settings = printer_settings + document.default_page_settings = page_settings + if dialog + print_dialog = F::PrintDialog.new + print_dialog.document = document + print_dialog.use_ex_dialog = true + return false unless print_dialog.show_dialog(owner) == F::DialogResult.OK + else + document.print_controller = D::Printing::StandardPrintController.new + end + lines = page = nil + document.begin_print do + lines = text.to_s.split("\n", -1).map { expand_tabs(it) } + page = 0 + end + document.print_page do |_, e| + page += 1 + e.has_more_pages = print_page(e, lines, font, title, page) + end + document.print + true + end + + private + + def print_page(e, lines, font, title, page) + g = e.graphics + area = e.margin_bounds + height = font.get_height(g) + centre = D::StringFormat.new + centre.alignment = D::StringAlignment.Center + g.draw_string(title, font, D::Brushes.Black, D::RectangleF.new(area.left, area.top, area.width, height * 1.5), centre) + g.draw_string("Page #{page}", font, D::Brushes.Black, + D::RectangleF.new(area.left, area.bottom - height, area.width, height * 1.5), centre) + format = D::StringFormat.new(D::StringFormat.generic_typographic) + format.format_flags = format.format_flags | D::StringFormatFlags.LineLimit + y = area.top + height * 2 + bottom = area.bottom - height * 2 + while !lines.empty? && y + height <= bottom + line = lines.first + if line.empty? + lines.shift + else + fit = fitting(g, line, font, area.width, height, format) + g.draw_string(line[0, fit], font, D::Brushes.Black, D::RectangleF.new(area.left, y, area.width, height * 1.5), format) + fit >= line.length ? lines.shift : lines[0] = line[fit, line.length - fit] + end + y += height + end + !lines.empty? + end + + # How much of the line fits on one printed line, broken at a word where possible. + def fitting(g, line, font, width, height, format) + chars = System::Runtime::CompilerServices::StrongBox[System::Int32].new(0) + rows = System::Runtime::CompilerServices::StrongBox[System::Int32].new(0) + g.measure_string(line, font, D::SizeF.new(width, height * 1.5), format, chars, rows) + [chars.value, 1].max + end + + def expand_tabs(line) + return line unless line.include?("\t") + out = String.new + line.each_char { |c| c == "\t" ? out << (" " * (8 - (out.length % 8))) : out << c } + out + end + end + + # One Notepad window. + class Window + attr_reader :form, :editor, :path, :encoding, :eol, :search, :printer + + def initialize(app, cascade_from: nil) + @app = app + @settings = app.settings + @search = app.search + @printer = Printer.new + @path = nil + @encoding = :utf8 + @eol = :crlf + @editor = Editor.new + @editor.on_zoom = ->(how) { zoom(how) } + @editor.on_redo = -> { @editor.box.redo } + @form = F::Form.new + @form.icon = app.icon + build_menu + build_status_bar + build_context_menu + @form.controls.add(@editor.box) # first, so that it fills what menu and status bar leave + @form.controls.add(@status) + @form.controls.add(@menu) + @form.main_menu_strip = @menu + apply_settings(cascade_from) + wire + update_title + update_status + end + + # Notepad's answer to a file name that does not exist: offer to create it. + def open_from_command_line(name) + path = windows_path(name) + return open_path(path) if File.exist?(path) + answer = F::MessageBox.show(@form, "Cannot find the #{path} file.\n\nDo you want to create a new file?", + APP_NAME, F::MessageBoxButtons.YesNo, F::MessageBoxIcon.Warning) + return unless answer == F::DialogResult.Yes + guarded { File.write(path, "") } + @path = path + update_title + end + + def open_path(path) + path = windows_path(path) + guarded do + text, @encoding, @eol = TextFile.read(path) + @editor.load_text(text) + @path = path + end + update_title + update_status + end + + def save + @path ? write_to(@path) : save_as + end + + def save_as + dialog = F::SaveFileDialog.new + dialog.filter = FILTER + dialog.default_ext = "txt" + dialog.file_name = @path ? File.basename(@path) : "*.txt" + dialog.initial_directory = File.dirname(@path) if @path + return false unless dialog.show_dialog(@form) == F::DialogResult.OK + write_to(dialog.file_name.to_s) + end + + def write_to(path) + path = windows_path(path) + text = @editor.text + if !TextFile.lossless?(text, @encoding) && !lossy_save_confirmed? + return false + end + return false unless guarded { TextFile.write(path, text, @encoding, @eol) } + @path = path + @editor.modified = false + update_title + true + end + + # true when there is nothing to lose, or the user chose Save (and it saved) or Don't Save. + def confirm_discard + return true unless @editor.modified + save = F::TaskDialogButton.new("&Save") + discard = F::TaskDialogButton.new("Do&n't Save") + @prompt = F::TaskDialogPage.new + @prompt.caption = APP_NAME + @prompt.heading = "Do you want to save changes to #{@path || 'Untitled'}?" + @prompt.buttons.add(save) + @prompt.buttons.add(discard) + # A button of our own, as the standard one speaks the language of Windows; Escape + # and the close box still cancel, through allow_cancel. + @prompt.buttons.add(F::TaskDialogButton.new("Cancel")) + @prompt.allow_cancel = true + @prompt.default_button = save + answer = F::TaskDialog.show_dialog(@form, @prompt) + return self.save if answer.equal?(save) + answer.equal?(discard) + end + + def find_next(up: false, owner: @form) + what = @search[:text].to_s + return show_find if what.empty? + found = @editor.find(what, match_case: @search[:match_case], up: up, wrap: @search[:wrap]) + cannot_find(owner, what) unless found + found + end + + def replace_next(owner: @form) + @editor.replace_selection(@search[:text].to_s, @search[:replace].to_s, @search[:match_case]) + find_next(owner: owner) + end + + def replace_all(owner: @form) + what = @search[:text].to_s + count = @editor.replace_all(what, @search[:replace].to_s, @search[:match_case]) + cannot_find(owner, what) if count.zero? + count + end + + def zoom(how) + percent = (@editor.zoom * 100).round + percent = case how + when :in then [percent + 10, 500].min + when :out then [percent - 10, 10].max + else 100 + end + @editor.zoom = percent / 100.0 + update_status + end + + def set_encoding(kind) + return if kind == @encoding + @encoding = kind + @editor.modified = true + update_title + update_status + end + + def set_line_ending(kind) + return if kind == @eol + @eol = kind + @editor.modified = true + update_title + update_status + end + + def store_settings + font = @editor.font + @settings["font_family"] = font.font_family.name.to_s + @settings["font_size"] = font.size_in_points.to_f + @settings["bold"] = font.bold + @settings["italic"] = font.italic + @settings["word_wrap"] = @editor.wrap + @settings["status_bar"] = @status_bar + maximized = @form.window_state == F::FormWindowState.Maximized + bounds = @form.window_state == F::FormWindowState.Normal ? @form.bounds : @form.restore_bounds + @settings["window"] = [bounds.x, bounds.y, bounds.width, bounds.height] + @settings["maximized"] = maximized + end + + def display_name = @path ? File.basename(@path) : "Untitled" + + private + + # As Windows writes it: Ruby's own paths use forward slashes. + def windows_path(path) = System::IO::Path.get_full_path(path).to_s + + def command(text, keys = nil, display: nil, &action) + item = F::ToolStripMenuItem.new(text) + item.shortcut_keys = keys if keys + item.shortcut_key_display_string = display if display + item.click { action.call } if action + item + end + + def menu(text, *items) + menu = F::ToolStripMenuItem.new(text) + items.each { menu.drop_down_items.add(it == :separator ? F::ToolStripSeparator.new : it) } + menu + end + + # A menu of choices with a check at the current one; a status-bar button and a + # Format submenu each get their own. + def choices(owner, names, current, &choose) + items = names.map do |key, name| + item = F::ToolStripMenuItem.new(name) + item.click { choose.call(key) } + owner.drop_down_items.add(item) + [key, item] + end + owner.drop_down_opening { items.each { |key, item| item.checked = key == current.call } } + owner + end + + def build_menu + ctrl = K.Control + file = menu("&File", + command("&New", ctrl | K.N) { new_document }, + command("New &Window", ctrl | K.Shift | K.N) { @app.open_window(source: self) }, + command("&Open...", ctrl | K.O) { open_dialog }, + command("&Save", ctrl | K.S) { save }, + command("Save &As...", ctrl | K.Shift | K.S) { save_as }, + :separator, + command("Page Set&up...") { guarded { @printer.page_setup(@form) } }, + command("&Print...", ctrl | K.P) { guarded { @printer.print_text(@form, @editor.text, @editor.font, display_name) } }, + :separator, + command("E&xit") { @form.close }) + + box = @editor.box + @undo = command("&Undo", ctrl | K.Z) { box.undo } + @redo = command("&Redo", ctrl | K.Y) { box.redo } + @cut = command("Cu&t", ctrl | K.X) { box.cut } + @copy = command("&Copy", ctrl | K.C) { box.copy } + @paste = command("&Paste", ctrl | K.V) { box.paste } + @delete = command("De&lete", display: "Del") { @editor.delete } + @find = command("&Find...", ctrl | K.F) { show_find } + @find_next = command("Find &Next", K.F3) { find_next } + @find_previous = command("Find Pre&vious", K.Shift | K.F3) { find_next(up: true) } + @replace = command("&Replace...", ctrl | K.H) { show_replace } + @go_to = command("&Go To...", ctrl | K.G) { go_to } + @edit_commands = [@undo, @redo, @cut, @copy, @paste, @delete, @find, @find_next, @find_previous, @replace, @go_to] + edit = menu("&Edit", @undo, @redo, :separator, @cut, @copy, @paste, @delete, :separator, + @find, @find_next, @find_previous, @replace, @go_to, :separator, + command("Select &All", ctrl | K.A) { box.select_all }, + command("Time/&Date", K.F5) { insert_time_date }) + edit.drop_down_opening { update_edit_menu } + # Shortcuts of disabled items do nothing, so they are enabled again on closing. + edit.drop_down_closed { @edit_commands.each { it.enabled = true } } + + @word_wrap_item = command("&Word Wrap") { toggle_word_wrap } + encodings = choices(F::ToolStripMenuItem.new("&Encoding"), TextFile::ENCODINGS, -> { @encoding }) { set_encoding(it) } + endings = choices(F::ToolStripMenuItem.new("&Line Endings"), TextFile::LINE_ENDINGS, -> { @eol }) { set_line_ending(it) } + format = menu("F&ormat", @word_wrap_item, command("&Font...") { choose_font }, :separator, encodings, endings) + + zoom = menu("&Zoom", + command("Zoom &In", ctrl | K.Oemplus, display: "Ctrl+Plus") { zoom(:in) }, + command("Zoom &Out", ctrl | K.OemMinus, display: "Ctrl+Minus") { zoom(:out) }, + command("&Restore Default Zoom", ctrl | K.D0, display: "Ctrl+0") { zoom(:reset) }) + @status_bar_item = command("&Status Bar") { toggle_status_bar } + view = menu("&View", zoom, @status_bar_item) + help = menu("&Help", command("&About #{APP_NAME}") { about }) + + @menu = F::MenuStrip.new + [file, edit, format, view, help].each { @menu.items.add(it) } + end + + def build_status_bar + @status = F::StatusStrip.new + spring = F::ToolStripStatusLabel.new + spring.spring = true + @position = status_label + @characters = status_label + @zoom_label = status_label + @eol_button = choices(status_button, TextFile::LINE_ENDINGS, -> { @eol }) { set_line_ending(it) } + @encoding_button = choices(status_button, TextFile::ENCODINGS, -> { @encoding }) { set_encoding(it) } + @eol_button.tool_tip_text = "Line endings" + @encoding_button.tool_tip_text = "Encoding" + [spring, @position, @characters, @zoom_label, @eol_button, @encoding_button].each_with_index do |item, i| + @status.items.add(F::ToolStripSeparator.new) if i.positive? + @status.items.add(item) + end + end + + def status_label + label = F::ToolStripStatusLabel.new + label.auto_size = false + label.text_align = D::ContentAlignment.MiddleLeft + label + end + + def status_button + button = F::ToolStripDropDownButton.new + button.show_drop_down_arrow = false + button.auto_size = false + button.display_style = F::ToolStripItemDisplayStyle.Text + button.text_align = D::ContentAlignment.MiddleLeft + button + end + + def status_widths + scale = @form.device_dpi / 96.0 + [[@position, 130], [@characters, 150], [@zoom_label, 50], [@eol_button, 120], [@encoding_button, 120]].each do |item, width| + item.width = (width * scale).round + end + end + + def build_context_menu + box = @editor.box + menu = F::ContextMenuStrip.new + undo = command("&Undo") { box.undo } + cut = command("Cu&t") { box.cut } + copy = command("&Copy") { box.copy } + paste = command("&Paste") { box.paste } + delete = command("&Delete") { @editor.delete } + select_all = command("Select &All") { box.select_all } + [undo, :separator, cut, copy, paste, delete, :separator, select_all].each do + menu.items.add(it == :separator ? F::ToolStripSeparator.new : it) + end + menu.opening do + selected = box.selection_length.positive? + undo.enabled = box.can_undo + cut.enabled = copy.enabled = delete.enabled = selected + paste.enabled = clipboard_has_text? + select_all.enabled = box.text_length.positive? + end + box.context_menu_strip = menu + end + + def apply_settings(cascade_from) + s = @settings + style = D::FontStyle.Regular + style |= D::FontStyle.Bold if s["bold"] + style |= D::FontStyle.Italic if s["italic"] + @editor.font = D::Font.new(s["font_family"].to_s, s["font_size"].to_f, style) + @editor.wrap = s["word_wrap"] ? true : false + @word_wrap_item.checked = @editor.wrap + @status_bar = s["status_bar"] ? true : false + @status.visible = @status_bar + @status_bar_item.checked = @status_bar + + scale = @form.device_dpi / 96.0 + @form.size = D::Size.new((900 * scale).round, (640 * scale).round) + bounds = s["window"] + if cascade_from + @form.start_position = F::FormStartPosition.Manual + offset = (30 * scale).round + @form.bounds = cascade_from.form.bounds + @form.location = D::Point.new(@form.left + offset, @form.top + offset) + elsif bounds.is_a?(Array) && bounds.length == 4 && on_screen?(D::Rectangle.new(*bounds.map(&:to_i))) + @form.start_position = F::FormStartPosition.Manual + @form.bounds = D::Rectangle.new(*bounds.map(&:to_i)) + @form.window_state = F::FormWindowState.Maximized if s["maximized"] + else + @form.start_position = F::FormStartPosition.WindowsDefaultLocation + end + end + + def on_screen?(rect) + F::Screen.all_screens.any? { it.working_area.intersects_with(rect) } + end + + def wire + box = @editor.box + @status_timer = F::Timer.new + @status_timer.interval = 40 + @status_timer.tick do + @status_timer.stop + update_status + end + box.selection_changed { schedule_status } + box.text_changed do + update_title + schedule_status + end + box.modified_changed { update_title } + box.drag_enter { |_, e| drag_enter(e) } + box.drag_drop { |_, e| drag_drop(e) } + @form.handle_created { status_widths } + @form.Load { @editor.apply_view } + @form.dpi_changed { status_widths } + @form.shown { box.focus } + @form.form_closing { |_, e| e.cancel = true unless confirm_discard } + @form.form_closed { @app.window_closed(self) } + end + + def schedule_status + @status_timer.stop + @status_timer.start + end + + def update_title + title = "#{@editor.modified ? '*' : ''}#{display_name} - #{APP_NAME}" + @form.text = title unless @form.text.to_s == title + end + + def update_status + line, column = @editor.line_and_column + @position.text = "Ln #{line}, Col #{column}" + total = @editor.length + selected = @editor.box.selection_length + count = ->(n) { System::String.format("{0:N0}", n).to_s } + @characters.text = + if selected.positive? then "#{count[selected]} of #{count[total]} characters" + else "#{count[total]} character#{total == 1 ? '' : 's'}" + end + @zoom_label.text = "#{(@editor.zoom * 100).round}%" + @eol_button.text = TextFile::LINE_ENDINGS[@eol] + @encoding_button.text = TextFile::ENCODINGS[@encoding] + end + + def update_edit_menu + box = @editor.box + selected = box.selection_length.positive? + text = box.text_length.positive? + @undo.enabled = box.can_undo + @redo.enabled = box.can_redo + @cut.enabled = @copy.enabled = @delete.enabled = selected + @paste.enabled = clipboard_has_text? + [@find, @find_next, @find_previous, @replace].each { it.enabled = text } + end + + def clipboard_has_text? + F::Clipboard.contains_text + rescue Exception # another program holds the clipboard open + true + end + + def new_document + return unless confirm_discard + @editor.load_text("") + @path = nil + @encoding = :utf8 + @eol = :crlf + update_title + update_status + end + + def open_dialog + return unless confirm_discard + dialog = F::OpenFileDialog.new + dialog.filter = FILTER + dialog.initial_directory = File.dirname(@path) if @path + open_path(dialog.file_name.to_s) if dialog.show_dialog(@form) == F::DialogResult.OK + end + + def lossy_save_confirmed? + text = "This file contains characters in Unicode format which will be lost if you save this file " \ + "as an #{TextFile::ENCODINGS[@encoding]} encoded text file. To keep the Unicode information, " \ + "click Cancel and then choose one of the Unicode options from the encoding on the status bar.\n\n" \ + "Continue?" + F::MessageBox.show(@form, text, APP_NAME, F::MessageBoxButtons.OKCancel, F::MessageBoxIcon.Warning) == + F::DialogResult.OK + end + + def show_find + @replace_dialog&.form&.hide + (@find_dialog ||= FindDialog.new(self, replace: false)).show + end + + def show_replace + @find_dialog&.form&.hide + (@replace_dialog ||= FindDialog.new(self, replace: true)).show + end + + def cannot_find(owner, what) + F::MessageBox.show(owner, "Cannot find \"#{what}\"", APP_NAME, F::MessageBoxButtons.OK, F::MessageBoxIcon.Information) + end + + def go_to + line = GoToDialog.ask(@form, @editor.line_and_column.first, @editor.line_count) + @editor.go_to_line(line) if line + end + + def insert_time_date + now = System::DateTime.now + @editor.insert("#{now.to_short_time_string} #{now.to_short_date_string}") + end + + def toggle_word_wrap + @editor.wrap = !@editor.wrap + @word_wrap_item.checked = @editor.wrap + update_status + end + + def toggle_status_bar + @status_bar = !@status_bar + @status.visible = @status_bar + @status_bar_item.checked = @status_bar + end + + def choose_font + dialog = F::FontDialog.new + dialog.font = @editor.font + dialog.show_effects = false + dialog.font_must_exist = true + dialog.allow_vertical_fonts = false + @editor.font = dialog.font if dialog.show_dialog(@form) == F::DialogResult.OK + end + + def about + lines = File.foreach(__FILE__).count + @prompt = F::TaskDialogPage.new + @prompt.caption = "About #{APP_NAME}" + @prompt.heading = APP_NAME + @prompt.text = "Notepad in #{lines} lines of Ruby, on IronRuby and Windows Forms.\n\n#{RUBY_DESCRIPTION}" + @prompt.icon = F::TaskDialogIcon.information + F::TaskDialog.show_dialog(@form, @prompt) + end + + def drag_enter(e) + e.effect = + if e.data.get_data_present(F::DataFormats.FileDrop) || e.data.get_data_present(F::DataFormats.UnicodeText) + F::DragDropEffects.Copy + else + F::DragDropEffects.None + end + end + + def drag_drop(e) + if e.data.get_data_present(F::DataFormats.FileDrop) + files = e.data.get_data(F::DataFormats.FileDrop) + @form.activate + open_path(files[0].to_s) if files.length.positive? && confirm_discard + else + box = @editor.box + box.select(box.get_char_index_from_position(box.point_to_client(D::Point.new(e.x, e.y))), 0) + @editor.insert(e.data.get_data(F::DataFormats.UnicodeText).to_s) + end + end + + # Runs the block; an exception becomes an error box. => true when it went through. + def guarded + yield + true + rescue Exception => e # .NET's exceptions are not all StandardErrors + F::MessageBox.show(@form, e.message.to_s, APP_NAME, F::MessageBoxButtons.OK, F::MessageBoxIcon.Error) + false + end + end + + class App + attr_reader :settings, :search, :icon, :context + + def initialize(settings) + @settings = settings + @search = { text: "", replace: "", match_case: settings["match_case"], wrap: settings["wrap_around"], up: false } + @icon = App.icon + @windows = [] + @context = F::ApplicationContext.new + end + + def open_window(path = nil, source: nil) + window = Window.new(self, cascade_from: source&.form&.visible ? source : nil) + @windows << window + window.form.show + window.open_from_command_line(path) if path + window + end + + def window_closed(window) + @windows.delete(window) + window.store_settings + @settings["match_case"] = @search[:match_case] + @settings["wrap_around"] = @search[:wrap] + @settings.save + @context.exit_thread if @windows.empty? + end + + # A page with a blue top edge and four lines of text, drawn rather than shipped. + def self.icon + bitmap = D::Bitmap.new(64, 64) + g = D::Graphics.from_image(bitmap) + g.smoothing_mode = D::Drawing2D::SmoothingMode.AntiAlias + g.fill_rectangle(D::Brushes.White, 10, 5, 44, 55) + g.draw_rectangle(D::Pen.new(D::Color.from_argb(255, 110, 110, 110), 2), 10, 5, 44, 55) + g.fill_rectangle(D::SolidBrush.new(D::Color.from_argb(255, 15, 100, 167)), 10, 5, 45, 11) + lines = D::Pen.new(D::Color.from_argb(255, 160, 160, 160), 3) + [26, 35, 44].each { g.draw_line(lines, 17, it, 47, it) } + g.draw_line(lines, 17, 53, 35, 53) + g.dispose + D::Icon.from_handle(bitmap.get_hicon) + end + end + + # What has to happen before the first window exists. + def self.prepare + F::Application.set_high_dpi_mode(F::HighDpiMode.PerMonitorV2) + F::Application.enable_visual_styles + F::Application.set_compatible_text_rendering_default(false) + F::Application.set_unhandled_exception_mode(F::UnhandledExceptionMode.CatchException) + F::Application.thread_exception do |_, e| + error = e.exception + trace = error.respond_to?(:backtrace) && error.backtrace ? error.backtrace.first(10).join("\n") : error.stack_trace.to_s + F::MessageBox.show("#{error.message}\n\n#{trace}", "#{APP_NAME} - Error", + F::MessageBoxButtons.OK, F::MessageBoxIcon.Error) + end + end + + def self.main(args) + prepare + Native.detach_console_if_alone + app = App.new(Settings.new) + app.open_window(args.first) + F::Application.run(app.context) + end +end + +IronRubyNotepad.main(ARGV) if $PROGRAM_NAME == __FILE__ diff --git a/Samples/Notepad/test_notepad.rb b/Samples/Notepad/test_notepad.rb new file mode 100644 index 00000000..5648d357 --- /dev/null +++ b/Samples/Notepad/test_notepad.rb @@ -0,0 +1,295 @@ +# Drives IronRuby Notepad with nobody at the keyboard: files in every encoding through +# open and save, byte for byte; editing, undo, find, replace, wrap, Ln/Col, zoom, tab +# stops, the formatting keys RichEdit must not see; printing (to PDF, where Windows has +# that printer); the modal dialogs, opened and closed again by a timer. +# +# ir.cmd Samples\Notepad\test_notepad.rb +# +# CI runs it on Windows. NOTEPAD_SCREENSHOTS= also saves a picture of each window. +# The test window stays on top while it runs, so that what the pictures show is it. +require_relative "notepad" + +N = IronRubyNotepad +F = N::F +D = N::D +K = N::K + +DIR = File.join(System::IO::Path.get_temp_path.to_s, "ironruby-notepad-test") +SHOTS = ENV["NOTEPAD_SCREENSHOTS"] +System::IO::Directory.create_directory(DIR) +Dir.glob(File.join(DIR, "*")).each { File.delete(it) } +System::IO::Directory.create_directory(SHOTS) if SHOTS +$failures = 0 + +def check(label, expected = true) + actual = yield + ok = expected == actual + $failures += 1 unless ok + puts "#{ok ? 'ok ' : 'FAIL'} #{label}#{ok ? '' : " - expected #{expected.inspect}, got #{actual.inspect}"}" +rescue Exception => e + $failures += 1 + puts "FAIL #{label} - #{e.class}: #{e.message}\n #{(e.backtrace || []).first(4).join("\n ")}" +end + +# Lets the window process its messages for a while, as Application.run would. +def pump(seconds = 0.15) + t = Time.now + while Time.now - t < seconds + F::Application.do_events + sleep 0.01 + end +end + +def screenshot(rect, name) + return unless SHOTS + bitmap = D::Bitmap.new(rect.width, rect.height) + g = D::Graphics.from_image(bitmap) + g.copy_from_screen(rect.location, D::Point.empty, rect.size) + g.dispose + bitmap.save(File.join(SHOTS, name), D::Imaging::ImageFormat.png) + bitmap.dispose +rescue Exception => e # a session without a screen still runs the test + puts "no screenshot #{name}: #{e.message}" +end + +# A form drawn into a bitmap by itself, wherever it is on screen and whatever covers it. +def picture(form, name) + return unless SHOTS + bitmap = D::Bitmap.new(form.width, form.height) + form.draw_to_bitmap(bitmap, D::Rectangle.new(0, 0, form.width, form.height)) + bitmap.save(File.join(SHOTS, name), D::Imaging::ImageFormat.png) + bitmap.dispose +rescue Exception => e + puts "no picture #{name}: #{e.message}" +end + +# A modal dialog runs a message loop of its own; the timer fires inside it. +def while_modal(delay = 0.9, &block) + timer = F::Timer.new + timer.interval = (delay * 1000).to_i + timer.tick do + timer.stop + block.call + end + timer.start +end + +def bytes(encoding, text, bom = []) + body = encoding.get_bytes(text.to_clr_string) + all = System::Array[System::Byte].new(bom.length + body.length) + bom.each_with_index { |b, i| all[i] = b } + body.copy_to(all, bom.length) + all +end + +def base64(path) = System::Convert.to_base64_string(System::IO::File.read_all_bytes(path)).to_s + +N.prepare +app = N::App.new(N::Settings.new(File.join(DIR, "settings.json"))) +w = app.open_window +w.form.top_most = true +pump 1.0 +box = w.editor.box +position = w.instance_variable_get(:@position) +status = -> { w.send(:update_status) } + +# Open and save every encoding Notepad offers; the saved file must be the opened one. +utf8 = System::Text::UTF8Encoding.new(false) +encodings = System::Text::Encoding +[ + ["utf8_lf.txt", bytes(utf8, "Grüezi mitenand\nZeile zwei\n\tmit Tab\n"), :utf8, :lf, + "Grüezi mitenand\nZeile zwei\n\tmit Tab\n"], + ["utf8bom_crlf.txt", bytes(utf8, "a\r\nb\r\n", [0xEF, 0xBB, 0xBF]), :utf8_bom, :crlf, "a\nb\n"], + ["utf16le_crlf.txt", bytes(encodings.unicode, "Hello\r\nWörld €", [0xFF, 0xFE]), :utf16le, :crlf, "Hello\nWörld €"], + ["utf16be_lf.txt", bytes(encodings.big_endian_unicode, "x\ny", [0xFE, 0xFF]), :utf16be, :lf, "x\ny"], + ["ansi_cr.txt", bytes(encodings.get_encoding(1252), "Käse\rBrot"), :ansi, :cr, "Käse\nBrot"], + # RichTextBox reads text that starts like RTF as RTF; a text file must stay text. + ["rtf_like.txt", bytes(utf8, "{\\rtf1 not rtf}\r\n"), :utf8, :crlf, "{\\rtf1 not rtf}\n"], + ["empty.txt", bytes(utf8, ""), :utf8, :crlf, ""] +].each do |name, data, encoding, eol, text| + path = File.join(DIR, name) + System::IO::File.write_all_bytes(path, data) + w.open_path(path) + pump 0.05 + check("#{name}: detected #{encoding}, #{eol}", [encoding, eol]) { [w.encoding, w.eol] } + check("#{name}: text") { w.editor.text.to_s == text } + copy = File.join(DIR, "saved_#{name}") + w.write_to(copy) + check("#{name}: saved byte for byte") { base64(copy) == base64(path) } +end + +w.open_path(File.join(DIR, "utf8_lf.txt")) +pump +check("title", "utf8_lf.txt - IronRuby Notepad") { w.form.text.to_s } +box.select(0, 0) +w.editor.insert("1") +box.select(1, 0) +w.editor.insert("2") +pump +check("title marks a change", "*utf8_lf.txt - IronRuby Notepad") { w.form.text.to_s } +check("undo, undo, redo", %w[1Grü Grü 1Grü]) do + box.undo + a = w.editor.text.to_s[0, 4] + box.undo + b = w.editor.text.to_s[0, 3] + box.redo + [a, b, w.editor.text.to_s[0, 4]] +end +check("RichEdit is in plain-text mode") { (N::Native.send_message(box, 0x045A) & 1) == 1 } # EM_GETTEXTMODE + +key = ->(keys) { F::KeyEventArgs.new(keys).tap { w.editor.key_down(it) }.suppress_key_press } +check("Ctrl+E (centre) is swallowed") { key[K.Control | K.E] } +check("Ctrl+Shift+> (bigger font) is swallowed") { key[K.Control | K.Shift | K.OemPeriod] } +check("AltGr+E (euro sign) passes", false) { key[K.Control | K.Alt | K.E] } +check("Ctrl+Backspace passes", false) { key[K.Control | K.Back] } +check("Ctrl+numpad plus zooms in", "110%") do + key[K.Control | K.Add] + status.call + w.instance_variable_get(:@zoom_label).text.to_s +end +check("zoom out twice", "90%") do + w.zoom(:out) + w.zoom(:out) + w.instance_variable_get(:@zoom_label).text.to_s +end +w.zoom(:reset) + +box.select(0, 0) +w.search[:text] = "zeile" +w.search[:match_case] = false +check("Find Next ignores case", "Zeile") { w.find_next && box.selected_text.to_s } +check("with Match case it finds nothing") { !w.editor.find("zeile", match_case: true, up: false, wrap: true) } +check("Find Previous wraps around", "Zeile") do + box.select(0, 0) + w.editor.find("Zeile", match_case: true, up: true, wrap: true) && box.selected_text.to_s +end +# The text is "1Grüezi mitenand\nZeile zwei\n\tmit Tab\n" here: five lowercase e. +check("Replace All", [5, "1GrüEzi mitEnand"]) do + n = w.editor.replace_all("e", "E", true) + [n, w.editor.text.to_s.lines.first.chomp] +end +check("Replace All is one undo step", "1Grüezi mitenand") do + box.undo + w.editor.text.to_s.lines.first.chomp +end + +long = "short\n#{'word ' * 300}\nlast" +w.editor.load_text(long) +w.editor.wrap = true +box.focus +box.select(long.length, 0) +pump 0.2 +check("wrapping makes more lines on screen") { box.get_line_from_char_index(box.text_length) > 5 } +check("Ln/Col counts lines of the file when wrapped", "Ln 3, Col 5") { status.call; position.text.to_s } +check("Ln/Col of a selection is at its caret end", "Ln 1, Col 6") do + box.select(0, 5) + status.call + position.text.to_s +end +check("Go To line 2", 6) { w.editor.go_to_line(2); box.selection_start } +check("line count", 3) { w.editor.line_count } +w.editor.wrap = false +pump +check("unwrapped, the same Ln/Col", "Ln 3, Col 5") { box.select(long.length, 0); status.call; position.text.to_s } + +w.editor.load_text("\tX\nMMMMMMMM") +pump +tab = -> { [box.get_position_from_char_index(1).x, box.get_position_from_char_index(11).x] } +check("a tab is 8 characters wide") { a, b = tab.call; (a - b).abs <= 1 } +check("and still is after a font change") do + w.editor.font = D::Font.new("Consolas", 16.0) + pump + a, b = tab.call + (a - b).abs <= 1 +end +w.editor.font = D::Font.new("Consolas", 11.0) + +check("changing the encoding marks the document", ["UTF-16 LE", true]) do + w.set_encoding(:utf16le) + status.call + [w.instance_variable_get(:@encoding_button).text.to_s, w.form.text.to_s.start_with?("*")] +end +check("ANSI holds umlauts and the euro sign") { N::TextFile.lossless?("Grüezi €", :ansi) } +check("ANSI cannot hold an arrow", false) { N::TextFile.lossless?("a → b", :ansi) } + +pdf_printer = "Microsoft Print to PDF" +printers = begin + D::Printing::PrinterSettings.installed_printers.to_a.map(&:to_s) +rescue Exception # no print spooler + [] +end +if printers.include?(pdf_printer) + pdf = File.join(DIR, "printed.pdf") + settings = w.printer.printer_settings + settings.printer_name = pdf_printer + settings.print_to_file = true + settings.print_file_name = pdf + text = "#{(1..130).map { "Line #{it}\twith a tab" }.join("\n")}\n#{'long ' * 200}" + check("prints") { w.printer.print_text(w.form, text, w.editor.font, "printed.txt", dialog: false) } + pump 3.0 + check("on three pages or more") { File.exist?(pdf) && File.binread(pdf).scan(%r{/Type\s*/Page[^s]}).length >= 3 } +else + puts "skip printing: no #{pdf_printer} here" +end + +sample = File.join(DIR, "screenshot.txt") +File.binwrite(sample, "IronRuby Notepad\r\n\r\nRuby on .NET, Windows Forms, RichEdit in plain-text mode.\r\n" \ + "\tTabs are eight characters, as in Notepad.\r\nGrüezi, ça va? Tschüss! €\r\n") +w.open_path(sample) +scale = w.form.device_dpi / 96.0 +w.form.bounds = D::Rectangle.new(80, 80, (720 * scale).round, (420 * scale).round) +box.select(20, 0) +pump 0.5 +check("a file Ruby wrote is CRLF, UTF-8", %i[utf8 crlf]) { [w.encoding, w.eol] } +screenshot(w.form.bounds, "main.png") + +w.search[:text] = "Ruby" +w.send(:show_find) +pump 0.6 +find = w.instance_variable_get(:@find_dialog).form +screenshot(find.bounds, "find.png") +w.send(:show_replace) +pump 0.6 +screenshot(w.instance_variable_get(:@replace_dialog).form.bounds, "replace.png") +check("Find goes away when Replace comes", false) { find.visible } +w.instance_variable_get(:@replace_dialog).form.hide +pump 0.2 + +while_modal do + form = nil + F::Application.open_forms.each { form = it if it.text.to_s == "Go To Line" } + if form + picture(form, "goto.png") # a modal dialog stays behind an owner that is on top + form.close + end +end +check("Go To, cancelled, goes nowhere", nil) { w.send(:go_to) } + +box.select(0, 0) +w.editor.insert("x") +while_modal do + screenshot(w.form.bounds, "save_prompt.png") + w.instance_variable_get(:@prompt).bound_dialog.close +end +check("Cancel on the save prompt keeps the window", false) { w.confirm_discard } +check("the prompt names the file as Windows writes it") do + w.instance_variable_get(:@prompt).heading.to_s.include?("\\screenshot.txt") +end + +while_modal do + screenshot(w.form.bounds, "about.png") + w.instance_variable_get(:@prompt).bound_dialog.close +end +w.send(:about) + +w.editor.modified = false +w.form.close +pump 0.5 +check("settings are saved on close") do + saved = JSON.parse(File.read(File.join(DIR, "settings.json"))) + saved["font_family"] == "Consolas" && saved["window"].is_a?(Array) +end + +puts +puts $failures.zero? ? "all passed" : "#{$failures} failed" +exit($failures.zero? ? 0 : 1)