Thread Widget#init
[amazing.git] / amazing.rb
blob78a70ec17dab01f0859161bbdf2a6f77a399d4b0
1 #!/usr/bin/env ruby
4 # = amazing 
6 # an amazing widget manager for an awesome window manager
8 #   Usage: amazing [options]
9 #      -c, --config FILE                Configuration file (~/.amazing.yml)
10 #      -s, --screen ID                  Screen ID (0)
11 #      -l, --log-level LEVEL            Severity threshold (info)
12 #      -i, --include SCRIPT             Include a widgets script
13 #      -u, --update WIDGET              Update a widget and exit
14 #      -w, --list-widgets               List available widgets
15 #      -h, --help                       You're looking at it
17 # == Widgets
19 # * Battery: Remaining battery power in percentage
20 # * Maildir: Mail count in maildirs
21 # * ALSA: Various data for the ALSA mixer
22 # * Raggle: Unread posts in raggle
23 # * Memory: Various memory related data
24 # * Clock: Displays date and time
26 # == Configuration
28 #   include:
29 #     - list
30 #     - of
31 #     - scripts
32 #   screens:
33 #     - list
34 #     - of
35 #     - screens
36 #   widgets:
37 #     identifier:
38 #       type: WidgetName
39 #       every: seconds
40 #       field: instance method
41 #       options:
42 #         widget: foo
43 #         specific: bar
45 # == Example
46
47 #   widgets:
48 #     pb_bat:
49 #       type: Battery
50 #       every: 10
51 #     tb_time:
52 #       type: Clock
53 #       every: 1
54 #       options:
55 #         format: %T
56 #     tb_mail:
57 #       type: Maildir
58 #       options:
59 #         directories:
60 #           - Mail/**/new
61 #           - Mail/inbox/cur
63 # In this example tb_mail doesn't have an "every" setting and is instead
64 # updated manually with <tt>amazing -u tb_mail</tt>, perhaps in cron after fetching
65 # new mail via fdm, getmail, fetchmail or similar. A good idea is also to
66 # update after closing your MUA such as Mutt which could be done with
67 # shell functions, example:
69 #   mutt() {
70 #     mutt $*
71 #     amazing -u tb_mail
72 #   }
74 # == Writing widgets
76 # Widgets inherit from Widget, serves data via instance methods, signalizes
77 # errors by raising a WidgetError and processes widget options via @options.
78 # The init method is used instead of initialize. Here's an example:
80 #   class MyWidget < Widget
81 #     description "This is my widget"
82 #     attr_reader :my_field
83 #     alias_method :default, :my_field
85 #     def init
86 #       @my_field = @options["text"] || "No text configured"
87 #       raise WidgetError, "oops!" if some_error?
88 #     end
89 #   end
91 # The ProcFile class can be used for parsing /proc files:
93 #   cpuinfo = ProcFile.new("cpuinfo")
94 #   cpuinfo[1]["model name"]
95 #   #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
97 # == Todo
99 # * Validate existence of awesome socket
100 # * Update widgets without an interval on start (when there is a socket)
101 # * Maybe auto-include scripts from ~/.amazing/something
102 # * Self-documenting widgets (list fields and options)
103 # * Some widgets need to support multiple data sources
104 # * More widgets, duh
106 # == Copying
108 # Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
109 # Licensed under the Academic Free License version 3.0
110 # http://www.rosenlaw.com/AFL3.0.htm
113 require 'optparse'
114 require 'logger'
115 require 'yaml'
116 require 'thread'
117 require 'pstore'
119 module Amazing
121   # Communicate with awesome
122   #
123   #   awesome = Awesome.new
124   #   awesome.widget_tell(widget_id, "Hello, world")
125   #   awesome = Awesome.new(1)
126   #   awesome.tag_view(3)
127   #   Awesome.new.client_zoom
128   class Awesome
129     attr_accessor :screen
131     def initialize(screen=0)
132       @screen = screen.to_i
133     end
135     def method_missing(method, *args)
136       IO.popen("awesome-client", IO::WRONLY) do |ac|
137         ac.puts "#@screen #{method} #{args.join(' ')}"
138       end
139     end
140   end
142   # Parse a /proc file
143   #
144   #   cpuinfo = ProcFile.new("cpuinfo")
145   #   cpuinfo[1]["model name"]
146   #   #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
147   class ProcFile
148     include Enumerable
150     def initialize(file)
151       file = "/proc/#{file}" if file[0] != ?/
152       @list = [{}]
153       File.readlines(file).each do |line|
154         if sep = line.index(":")
155           @list[-1][line[0..sep-1].strip] = line[sep+1..-1].strip
156         else
157           @list << {}
158         end
159       end
160       @list.pop if @list[-1].empty?
161     end
163     def each
164       @list.each do |section|
165         yield section
166       end
167     end
169     def [](section)
170       @list[section]
171     end
172   end
174   # Raised by widgets, and is then rescued and logged
175   class WidgetError < Exception
176   end
178   # Parent class for widget construction
179   #
180   #   class MyWidget < Widget
181   #     description "This is my widget"
182   #     attr_reader :my_field
183   #     alias_method :default, :my_field
184   #
185   #     def init
186   #       @my_field = @options["text"] || "No text configured"
187   #       raise WidgetError, "oops!" if some_error?
188   #     end
189   #   end
190   class Widget
191     def initialize(opts={})
192       @options = opts
193       init if respond_to? :init
194     end
196     def self.description(description=nil)
197       if description
198         @description = description
199       else
200         @description
201       end
202     end
203   end
205   module Widgets
206     class ALSA < Widget
207       description "Various data for the ALSA mixer"
208       attr_reader :volume
209       alias_method :default, :volume
211       def init
212         mixer = @options["mixer"] || "Master"
213         IO.popen("amixer get #{mixer}", IO::RDONLY) do |am|
214           out = am.read
215           volumes = out.scan(/\[(\d+)%\]/).flatten
216           @volume = 0
217           volumes.each {|vol| @volume += vol.to_i }
218           @volume = @volume / volumes.size
219         end
220       end
221     end
223     class Battery < Widget
224       description "Remaining battery power in percentage"
225       attr_reader :percentage
226       alias_method :default, :percentage
228       def init
229         battery = @options["battery"] || 1
230         batinfo = ProcFile.new("acpi/battery/BAT#{battery}/info")[0]
231         batstate = ProcFile.new("acpi/battery/BAT#{battery}/state")[0]
232         remaining = batstate["remaining capacity"].to_i
233         lastfull = batinfo["last full capacity"].to_i
234         @percentage = (remaining * 100) / lastfull.to_f
235       end
236     end
238     class Clock < Widget
239       description "Displays date and time"
240       attr_reader :time
241       alias_method :default, :time
243       def init
244         format = @options["format"] || "%R"
245         @time = Time.now.strftime(format)
246       end
247     end
249     class Maildir < Widget
250       description "Mail count in maildirs"
251       attr_reader :count
252       alias_method :default, :count
254       def init
255         @count = 0
256         raise WidgetError, "No directories configured" unless @options["directories"]
257         @options["directories"].each do |glob|
258           glob = "#{ENV["HOME"]}/#{glob}" if glob[0] != ?/
259           @count += Dir["#{glob}/*"].size
260         end
261       end
262     end
264     class Memory < Widget
265       description "Various memory related data"
266       attr_reader :total, :free, :buffers, :cached, :usage
267       alias_method :default, :usage
269       def init
270         meminfo = ProcFile.new("meminfo")[0]
271         @total = meminfo["MemTotal"].to_i
272         @free = meminfo["MemFree"].to_i
273         @buffers = meminfo["Buffers"].to_i
274         @cached = meminfo["Cached"].to_i
275         @usage = ((@total - @free - @cached - @buffers) * 100) / @total.to_f
276       end
277     end
279     class Raggle < Widget
280       description "Unread posts in raggle"
281       attr_reader :unread
282       alias_method :default, :unread
284       def init
285         feed_list = @options["feed_list_path"] || ".raggle/feeds.yaml"
286         feed_list = "#{ENV["HOME"]}/#{feed_list}" if feed_list[0] != ?/
287         feeds = YAML.load_file(feed_list)
288         feed_cache = @options["feed_cache_path"] || ".raggle/feed_cache.store"
289         feed_cache = "#{ENV["HOME"]}/#{feed_cache}" if feed_cache[0] != ?/
290         cache = PStore.new(feed_cache)
291         @unread = 0
292         cache.transaction(false) do
293           feeds.each do |feed|
294             cache[feed["url"]].each do |item|
295               @unread += 1 unless item["read?"]
296             end
297           end
298         end
299       end
300     end
301   end
303   # Parse and manage command line options
304   class Options
305     include Enumerable
307     def initialize(args)
308       @options = {}
309       @options[:config] = "#{ENV["HOME"]}/.amazing.yml"
310       @options[:screens] = []
311       @options[:loglevel] = "info"
312       @options[:include] = []
313       @options[:update] = []
314       @args = args
315       @parser = OptionParser.new do |opts|
316         opts.on("-c", "--config FILE", "Configuration file (~/.amazing.yml)") do |config|
317           @options[:config] = config
318         end
319         opts.on("-s", "--screen ID", "Screen ID (0)") do |screen|
320           @options[:screens] << screen
321         end
322         opts.on("-l", "--log-level LEVEL", "Severity threshold (info)") do |level|
323           @options[:loglevel] = level
324         end
325         opts.on("-i", "--include SCRIPT", "Include a widgets script") do |script|
326           @options[:include] << script
327         end
328         opts.on("-u", "--update WIDGET", "Update a widget and exit") do |widget|
329           @options[:update] << widget
330         end
331         opts.on("-w", "--list-widgets", "List available widgets") do
332           @options[:listwidgets] = true
333         end
334         opts.on("-h", "--help", "You're looking at it") do
335           @options[:help] = true
336         end
337       end
338     end
340     def each
341       @options.keys.each do |key|
342         yield key
343       end
344     end
346     def parse(args=@args)
347       @parser.parse!(args)
348     end
350     def help
351       @parser.help
352     end
354     def [](option)
355       @options[option]
356     end
358     def []=(option, value)
359       @options[option] = value
360     end
361   end
363   # Command line interface runner
364   #
365   #   CLI.run(ARGV)
366   class CLI
367     def initialize(args)
368       @args = args
369       @log = Logger.new(STDOUT)
370       @options = Options.new(@args)
371     end
373     def run
374       trap("SIGINT") do
375         @log.fatal("Received SIGINT, exiting")
376         exit
377       end
378       @options.parse
379       show_help if @options[:help]
380       set_loglevel
381       parse_config
382       load_scripts
383       list_widgets if @options[:listwidgets]
384       setup_screens
385       update_widgets unless @options[:update].empty?
386       count = 0
387       loop do
388         @config["widgets"].each do |widget_name, settings|
389           if settings["every"] && count % settings["every"] == 0
390             begin
391               @screens.each do |screen, awesome|
392                 @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
393                 Thread.new do
394                   opts = settings["options"] || {}
395                   widget = Widgets.const_get(settings["type"]).new(opts)
396                   field = settings["field"] || "default"
397                   awesome.widget_tell(widget_name, widget.__send__(field))
398                 end
399               end
400             rescue WidgetError => e
401               @log.error(settings["type"]) { e.message }
402             end
403           end
404         end
405         count += 1
406         sleep 1
407       end
408     end
410     private
412     def show_help
413       puts @options.help
414       exit
415     end
417     def set_loglevel
418       begin
419         @log.level = Logger.const_get(@options[:loglevel].upcase)
420       rescue NameError
421         @log.error("Unsupported log level #{@options[:loglevel].inspect}")
422         @log.level = Logger::INFO
423       end
424     end
426     def load_scripts
427       scripts = (@options[:include] + @config["include"]).uniq
428       scripts.each do |script|
429         begin
430           Widgets.module_eval("require #{script.inspect}")
431         rescue LoadError
432           @log.error("No such widget script #{script.inspect}")
433         end
434       end
435     end
437     def list_widgets
438       Widgets.constants.each do |widget|
439         if description = Widgets.const_get(widget).description
440           puts "#{widget}: #{description}"
441         else
442           puts widget
443         end
444       end
445       exit
446     end
448     def parse_config
449       @log.info("Parsing configuration file")
450       begin
451         @config = YAML.load_file(@options[:config])
452       rescue
453         @log.fatal("Unable to parse configuration file, exiting")
454         exit 1
455       end
456       @config["include"] ||= []
457       @config["screens"] ||= []
458     end
460     def setup_screens
461       @screens = {}
462       @options[:screens].each do |screen|
463         @screens[screen.to_i] = Awesome.new(screen)
464       end
465       if @screens.empty?
466         @config["screens"].each do |screen|
467           @screens[screen] = Awesome.new(screen)
468         end
469       end
470       @screens[0] = Awesome.new if @screens.empty?
471     end
473     def update_widgets
474       @config["widgets"].each do |widget_name, settings|
475         next unless @options[:update].include? widget_name
476         opts = settings["options"] || {}
477         begin
478           widget = Widgets.const_get(settings["type"]).new(opts)
479           field = settings["field"] || "default"
480           @screens.each do |screen, awesome|
481             @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
482             awesome.widget_tell(widget_name, widget.__send__(field))
483           end
484         rescue WidgetError => e
485           @log.error(settings["type"]) { e.message }
486         end
487       end
488       exit
489     end
490   end
493 if $0 == __FILE__
494   Amazing::CLI.new(ARGV).run