X-Git-Url: https://git.notmuchmail.org/git?a=blobdiff_plain;f=lib%2Fsup%2Fbuffer.rb;h=f053f676e53262f2680c52b46fb21134d437d69e;hb=aa5608229171a78694c64d0a30375e765ffe86e0;hp=62b582e958c565d0eb4736486f34d9eb3bf037a5;hpb=cd4336c03f01f6ba58fae14dacc3a86e9d091fc9;p=sup diff --git a/lib/sup/buffer.rb b/lib/sup/buffer.rb index 62b582e..f053f67 100644 --- a/lib/sup/buffer.rb +++ b/lib/sup/buffer.rb @@ -1,6 +1,8 @@ require 'etc' require 'thread' +require 'ncurses' +if defined? Ncurses module Ncurses def rows lame, lamer = [], [] @@ -35,16 +37,22 @@ module Ncurses module_function :rows, :cols, :curx, :nonblocking_getch, :mutex, :sync + remove_const :KEY_ENTER + remove_const :KEY_CANCEL + KEY_ENTER = 10 - KEY_CANCEL = ?\a # ctrl-g + KEY_CANCEL = 7 # ctrl-g KEY_TAB = 9 end +end module Redwood +class InputSequenceAborted < StandardError; end + class Buffer - attr_reader :mode, :x, :y, :width, :height, :title - bool_reader :dirty + attr_reader :mode, :x, :y, :width, :height, :title, :atime + bool_reader :dirty, :system bool_accessor :force_to_top def initialize window, mode, width, height, opts={} @@ -55,6 +63,8 @@ class Buffer @title = opts[:title] || "" @force_to_top = opts[:force_to_top] || false @x, @y, @width, @height = 0, 0, width, height + @atime = Time.at 0 + @system = opts[:system] || false end def content_height; @height - 1; end @@ -68,9 +78,13 @@ class Buffer mode.resize rows, cols end - def redraw - draw if @dirty - draw_status + def redraw status + if @dirty + draw status + else + draw_status status + end + commit end @@ -81,10 +95,11 @@ class Buffer @w.noutrefresh end - def draw + def draw status @mode.draw - draw_status + draw_status status commit + @atime = Time.now end ## s nil means a blank line! @@ -93,10 +108,16 @@ class Buffer @w.attrset Colormap.color_for(opts[:color] || :none, opts[:highlight]) s ||= "" - maxl = @width - x - @w.mvaddstr y, x, s[0 ... maxl] - unless s.length >= maxl || opts[:no_fill] - @w.mvaddstr(y, x + s.length, " " * (maxl - s.length)) + maxl = @width - x # maximum display width width + stringl = maxl # string "length" + ## the next horribleness is thanks to ruby's lack of widechar support + stringl += 1 while stringl < s.length && s[0 ... stringl].display_length < maxl + @w.mvaddstr y, x, s[0 ... stringl] + unless opts[:no_fill] + l = s.display_length + unless l >= maxl + @w.mvaddstr(y, x + l, " " * (maxl - l)) + end end end @@ -104,9 +125,8 @@ class Buffer @w.clear end - def draw_status - write @height - 1, 0, " [#{mode.name}] #{title} #{mode.status}", - :color => :status_color + def draw_status status + write @height - 1, 0, status, :color => :status_color end def focus @@ -127,6 +147,44 @@ class BufferManager attr_reader :focus_buf + ## we have to define the key used to continue in-buffer search here, because + ## it has special semantics that BufferManager deals with---current searches + ## are canceled by any keypress except this one. + CONTINUE_IN_BUFFER_SEARCH_KEY = "n" + + HookManager.register "status-bar-text", <" entries. + +Variables: none +Return value: an array of email address strings. +EOS + def initialize @name_map = {} @buffers = [] @@ -137,14 +195,13 @@ class BufferManager @textfields = {} @flash = nil @shelled = @asking = false - - self.class.i_am_the_instance self + @in_x = ENV["TERM"] =~ /(xterm|rxvt|screen)/ end def buffers; @name_map.to_a; end def focus_on buf - raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf + return unless @buffers.member? buf return if buf == @focus_buf @focus_buf.blur if @focus_buf @focus_buf = buf @@ -152,15 +209,13 @@ class BufferManager end def raise_to_front buf - raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf - - @buffers.delete buf + @buffers.delete(buf) or return if @buffers.length > 0 && @buffers.last.force_to_top? @buffers.insert(-2, buf) else @buffers.push buf - focus_on buf end + focus_on @buffers.last @dirty = true end @@ -184,7 +239,13 @@ class BufferManager end def handle_input c - @focus_buf && @focus_buf.mode.handle_input(c) + if @focus_buf + if @focus_buf.mode.in_search? && c != CONTINUE_IN_BUFFER_SEARCH_KEY[0] + @focus_buf.mode.cancel_search! + @focus_buf.mark_dirty + end + @focus_buf.mode.handle_input c + end end def exists? n; @name_map.member? n; end @@ -198,16 +259,29 @@ class BufferManager def completely_redraw_screen return if @shelled + status, title = get_status_and_title(@focus_buf) # must be called outside of the ncurses lock + Ncurses.sync do @dirty = true Ncurses.clear - draw_screen :sync => false + draw_screen :sync => false, :status => status, :title => title end end def draw_screen opts={} return if @shelled + status, title = + if opts.member? :status + [opts[:status], opts[:title]] + else + raise "status must be supplied if draw_screen is called within a sync" if opts[:sync] == false + get_status_and_title @focus_buf # must be called outside of the ncurses lock + end + + ## http://rtfm.etla.org/xterm/ctlseq.html (see Operating System Controls) + print "\033]0;#{title}\07" if title && @in_x + Ncurses.mutex.lock unless opts[:sync] == false ## disabling this for the time being, to help with debugging @@ -216,7 +290,7 @@ class BufferManager false && @buffers.inject(@dirty) do |dirty, buf| buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols #dirty ? buf.draw : buf.redraw - buf.draw + buf.draw status dirty end @@ -224,7 +298,7 @@ class BufferManager if true buf = @buffers.last buf.resize Ncurses.rows - minibuf_lines, Ncurses.cols - @dirty ? buf.draw : buf.redraw + @dirty ? buf.draw(status) : buf.redraw(status) end draw_minibuf :sync => false unless opts[:skip_minibuf] @@ -235,17 +309,21 @@ class BufferManager Ncurses.mutex.unlock unless opts[:sync] == false end - ## gets the mode from the block, which is only called if the buffer - ## doesn't already exist. this is useful in the case that generating - ## the mode is expensive, as it often is. + ## if the named buffer already exists, pops it to the front without + ## calling the block. otherwise, gets the mode from the block and + ## creates a new buffer. returns two things: the buffer, and a boolean + ## indicating whether it's a new buffer or not. def spawn_unless_exists title, opts={} - if @name_map.member? title - raise_to_front @name_map[title] unless opts[:hidden] - else - mode = yield - spawn title, mode, opts - end - @name_map[title] + new = + if @name_map.member? title + raise_to_front @name_map[title] unless opts[:hidden] + false + else + mode = yield + spawn title, mode, opts + true + end + [@name_map[title], new] end def spawn title, mode, opts={} @@ -267,7 +345,7 @@ class BufferManager ## w = Ncurses::WINDOW.new(height, width, (opts[:top] || 0), ## (opts[:left] || 0)) w = Ncurses.stdscr - b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => (opts[:force_to_top] || false) + b = Buffer.new w, mode, width, height, :title => realtitle, :force_to_top => opts[:force_to_top], :system => opts[:system] mode.buffer = b @name_map[realtitle] = b @@ -289,7 +367,10 @@ class BufferManager c = Ncurses.nonblocking_getch next unless c # getch timeout break if c == Ncurses::KEY_CANCEL - mode.handle_input c + begin + mode.handle_input c + rescue InputSequenceAborted # do nothing + end draw_screen erase_flash end @@ -318,7 +399,7 @@ class BufferManager end def kill_buffer buf - raise ArgumentError, "buffer not on stack: #{buf.inspect}" unless @buffers.member? buf + raise ArgumentError, "buffer not on stack: #{buf}: #{buf.title.inspect}" unless @buffers.member? buf buf.mode.cleanup @buffers.delete buf @@ -334,30 +415,36 @@ class BufferManager def ask_with_completions domain, question, completions, default=nil ask domain, question, default do |s| - completions.select { |x| x =~ /^#{s}/i }.map { |x| [x.downcase, x] } + completions.select { |x| x =~ /^#{Regexp::escape s}/i }.map { |x| [x, x] } end end - def ask_many_with_completions domain, question, completions, default=nil, sep=" " + def ask_many_with_completions domain, question, completions, default=nil ask domain, question, default do |partial| prefix, target = - case partial.gsub(/#{sep}+/, sep) + case partial when /^\s*$/ ["", ""] - when /^(.+#{sep})$/ - [$1, ""] - when /^(.*#{sep})?(.+?)$/ + when /^(.*\s+)?(.*?)$/ [$1 || "", $2] else raise "william screwed up completion: #{partial.inspect}" end - completions.select { |x| x =~ /^#{target}/i }.map { |x| [prefix + x, x] } + completions.select { |x| x =~ /^#{Regexp::escape target}/i }.map { |x| [prefix + x, x] } + end + end + + def ask_many_emails_with_completions domain, question, completions, default=nil + ask domain, question, default do |partial| + prefix, target = partial.split_on_commas_with_remainder + target ||= prefix.pop || "" + prefix = prefix.join(", ") + (prefix.empty? ? "" : ", ") + completions.select { |x| x =~ /^#{Regexp::escape target}/i }.sort_by { |c| [ContactManager.contact_for(c) ? 0 : 1, c] }.map { |x| [prefix + x, x] } end end - ## returns an ARRAY of filenames! - def ask_for_filenames domain, question, default=nil + def ask_for_filename domain, question, default=nil answer = ask domain, question, default do |s| if s =~ /(~([^\s\/]*))/ # twiddle directory expansion full = $1 @@ -366,7 +453,7 @@ class BufferManager if dir [[s.sub(full, dir), "~#{name}"]] else - users.select { |u| u =~ /^#{name}/ }.map do |u| + users.select { |u| u =~ /^#{Regexp::escape name}/ }.map do |u| [s.sub("~#{name}", "~#{u}"), "~#{u}"] end end @@ -379,31 +466,34 @@ class BufferManager end if answer - answer = + answer = if answer.empty? spawn_modal "file browser", FileBrowserMode.new elsif File.directory?(answer) spawn_modal "file browser", FileBrowserMode.new(answer) else - [answer] + File.expand_path answer end end - answer || [] + answer end ## returns an array of labels def ask_for_labels domain, question, default_labels, forbidden_labels=[] - default = default_labels.join(" ") + default_labels = default_labels - forbidden_labels - LabelManager::RESERVED_LABELS + default = default_labels.to_a.join(" ") default += " " unless default.empty? - applyable_labels = (LabelManager.applyable_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase } + # here I would prefer to give more control and allow all_labels instead of + # user_defined_labels only + applyable_labels = (LabelManager.user_defined_labels - forbidden_labels).map { |l| LabelManager.string_for l }.sort_by { |s| s.downcase } - answer = BufferManager.ask_many_with_completions domain, question, applyable_labels, default + answer = ask_many_with_completions domain, question, applyable_labels, default return unless answer - user_labels = answer.split(/\s+/).map { |l| l.intern } + user_labels = answer.to_set_of_symbols user_labels.each do |l| if forbidden_labels.include?(l) || LabelManager::RESERVED_LABELS.include?(l) BufferManager.flash "'#{l}' is a reserved label!" @@ -413,30 +503,42 @@ class BufferManager user_labels end + def ask_for_contacts domain, question, default_contacts=[] + default = default_contacts.map { |s| s.to_s }.join(" ") + default += " " unless default.empty? + + recent = Index.load_contacts(AccountManager.user_emails, :num => 10).map { |c| [c.full_address, c.email] } + contacts = ContactManager.contacts.map { |c| [ContactManager.alias_for(c), c.full_address, c.email] } + + completions = (recent + contacts).flatten.uniq + completions += HookManager.run("extra-contact-addresses") || [] + answer = BufferManager.ask_many_emails_with_completions domain, question, completions, default + + if answer + answer.split_on_commas.map { |x| ContactManager.contact_for(x) || Person.from_address(x) } + end + end + ## for simplicitly, we always place the question at the very bottom of the + ## screen def ask domain, question, default=nil, &block raise "impossible!" if @asking @asking = true - @textfields[domain] ||= TextField.new Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols + @textfields[domain] ||= TextField.new tf = @textfields[domain] completion_buf = nil - ## this goddamn ncurses form shit is a fucking 1970's nightmare. - ## jesus christ. the exact sequence of ncurses events that needs - ## to happen in order to display a form and have the entire screen - ## not disappear and have the cursor in the right place is TOO - ## FUCKING COMPLICATED. + status, title = get_status_and_title @focus_buf + Ncurses.sync do - tf.activate question, default, &block - @dirty = true - draw_screen :skip_minibuf => true, :sync => false + tf.activate Ncurses.stdscr, Ncurses.rows - 1, 0, Ncurses.cols, question, default, &block + @dirty = true # for some reason that blanks the whole fucking screen + draw_screen :sync => false, :status => status, :title => title + tf.position_cursor + Ncurses.refresh end - ret = nil - tf.position_cursor - Ncurses.sync { Ncurses.refresh } - while true c = Ncurses.nonblocking_getch next unless c # getch timeout @@ -462,45 +564,48 @@ class BufferManager Ncurses.sync { Ncurses.refresh } end - Ncurses.sync { tf.deactivate } kill_buffer completion_buf if completion_buf + @dirty = true @asking = false - draw_screen + Ncurses.sync do + tf.deactivate + draw_screen :sync => false, :status => status, :title => title + end tf.value end - ## some pretty lame code in here! def ask_getch question, accept=nil + raise "impossible!" if @asking + accept = accept.split(//).map { |x| x[0] } if accept - flash question + status, title = get_status_and_title @focus_buf Ncurses.sync do - Ncurses.curs_set 1 + draw_screen :sync => false, :status => status, :title => title + Ncurses.mvaddstr Ncurses.rows - 1, 0, question Ncurses.move Ncurses.rows - 1, question.length + 1 + Ncurses.curs_set 1 Ncurses.refresh end + @asking = true ret = nil done = false - @shelled = true until done key = Ncurses.nonblocking_getch or next if key == Ncurses::KEY_CANCEL done = true - elsif (accept && accept.member?(key)) || !accept + elsif accept.nil? || accept.empty? || accept.member?(key) ret = key done = true end end - @shelled = false - + @asking = false Ncurses.sync do Ncurses.curs_set 0 - erase_flash - draw_screen :sync => false - Ncurses.curs_set 0 + draw_screen :sync => false, :status => status, :title => title end ret @@ -518,6 +623,25 @@ class BufferManager end end + ## turns an input keystroke into an action symbol. returns the action + ## if found, nil if not found, and throws InputSequenceAborted if + ## the user aborted a multi-key sequence. (Because each of those cases + ## should be handled differently.) + ## + ## this is in BufferManager because multi-key sequences require prompting. + def resolve_input_with_keymap c, keymap + action, text = keymap.action_for c + while action.is_a? Keymap # multi-key commands, prompt + key = BufferManager.ask_getch text + unless key # user canceled, abort + erase_flash + raise InputSequenceAborted + end + action, text = action.action_for(key) if action.has_key?(key) + end + action + end + def minibuf_lines @minibuf_mutex.synchronize do [(@flash ? 1 : 0) + @@ -531,7 +655,7 @@ class BufferManager @minibuf_mutex.synchronize do m = @minibuf_stack.compact m << @flash if @flash - m << "" if m.empty? + m << "" if m.empty? unless @asking # to clear it end Ncurses.mutex.lock unless opts[:sync] == false @@ -597,6 +721,7 @@ class BufferManager Ncurses.sync do Ncurses.endwin system command + Ncurses.stdscr.keypad 1 Ncurses.refresh Ncurses.curs_set 0 end @@ -604,6 +729,30 @@ class BufferManager end private + def default_status_bar buf + " [#{buf.mode.name}] #{buf.title} #{buf.mode.status}" + end + + def default_terminal_title buf + "Sup #{Redwood::VERSION} :: #{buf.title}" + end + + def get_status_and_title buf + opts = { + :num_inbox => lambda { Index.num_results_for :label => :inbox }, + :num_inbox_unread => lambda { Index.num_results_for :labels => [:inbox, :unread] }, + :num_total => lambda { Index.size }, + :num_spam => lambda { Index.num_results_for :label => :spam }, + :title => buf.title, + :mode => buf.mode.name, + :status => buf.mode.status + } + + statusbar_text = HookManager.run("status-bar-text", opts) || default_status_bar(buf) + term_title_text = HookManager.run("terminal-title-text", opts) || default_terminal_title(buf) + + [statusbar_text, term_title_text] + end def users unless @users