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
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
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:
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 Clock < Widget
81 # description "Displays date and time"
82 # option :time_format, "Time format as described in DATE(1)", "%R"
83 # field :time, "Formatted time"
87 # @time = Time.now.strftime(@time_format)
88 # raise WidgetError, "An error occured!" if some_error?
92 # The ProcFile class can be used for parsing /proc files:
94 # cpuinfo = ProcFile.new("cpuinfo")
95 # cpuinfo[1]["model name"]
96 # #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
100 # * Maybe auto-include scripts from ~/.amazing/something
101 # * Self-documenting widgets (list fields and options) (done in widgets)
102 # * Some widgets need to support multiple data sources
103 # * Some way to do alerts, e.g. "blinking"
104 # * Make widget configuration screen specific
105 # * Support widgets with multiple bars and graphs (maybe wait for 2.3)
106 # * Perhaps use a single awesome-client process and keep it open
107 # * Maybe keep custom widget options at same level as other options
108 # * More widgets, duh
112 # Copyright (C) 2008 Dag Odenhall <dag.odenhall@gmail.com>
113 # Licensed under the Academic Free License version 3.0
114 # http://www.rosenlaw.com/AFL3.0.htm
128 # Raised by DisplayName#new if called with empty argument, or without
129 # argument and ENV["DISPLAY"] is empty.
130 class EmptyDisplayName < ArgumentError
133 # Raised by DisplayName#new if format of argument or ENV["DISPLAY"] is
135 class InvalidDisplayName < ArgumentError
138 # Parse an X11 display name
140 # display = DisplayName.new("hostname:displaynumber.screennumber")
141 # display.hostname #=> "hostname"
142 # display.display #=> "displaynumber"
143 # display.screen #=> "screennumber"
145 # Without arguments, reads ENV["DISPLAY"]. With empty argument or
146 # DISPLAY environment, raises EmptyDisplayName. With invalid display name
147 # format, raises InvalidDisplayName.
149 attr_reader :hostname, :display, :screen
151 def initialize(display_name=ENV["DISPLAY"])
152 raise EmptyDisplayName, "No display name supplied" if ["", nil].include? display_name
153 @hostname, @display, @screen = display_name.scan(/^(.*):(\d+)(?:\.(\d+))?$/)[0]
154 raise InvalidDisplayName, "Invalid display name" if @display.nil?
155 @hostname = "localhost" if @hostname.empty?
156 @screen = "0" unless @screen
161 # Communicate with awesome
163 # awesome = Awesome.new
164 # awesome.widget_tell(widget_id, "Hello, world")
165 # awesome = Awesome.new(1)
166 # awesome.tag_view(3)
167 # Awesome.new.client_zoom
169 attr_accessor :screen, :display
171 def initialize(screen=0, display=0)
172 @screen = screen.to_i
176 def method_missing(method, *args)
177 IO.popen("env DISPLAY=#{display} awesome-client", IO::WRONLY) do |ac|
178 ac.puts "#@screen #{method} #{args.join(' ')}"
185 # cpuinfo = ProcFile.new("cpuinfo")
186 # cpuinfo[1]["model name"]
187 # #=> "AMD Turion(tm) 64 X2 Mobile Technology TL-50"
192 file = "/proc/#{file}" if file[0] != ?/
194 File.readlines(file).each do |line|
195 if sep = line.index(":")
196 @list[-1][line[0..sep-1].strip] = line[sep+1..-1].strip
201 @list.pop if @list[-1].empty?
205 @list.each do |section|
215 # Raised by widgets, and is then rescued and logged
216 class WidgetError < Exception
219 # Parent class for widget construction, example:
221 # class Clock < Widget
222 # description "Displays date and time"
223 # option :time_format, "Time format as described in DATE(1)", "%R"
224 # field :time, "Formatted time"
228 # @time = Time.now.strftime(@time_format)
229 # raise WidgetError, "An error occured!" if some_error?
233 def initialize(identifier=nil, format=nil, opts={})
234 @identifier, @format = identifier, format
235 self.class.options.each do |key, value|
236 value = opts[key.to_s] || value[:default]
237 instance_variable_set "@#{key}".to_sym, value
239 self.class.fields.each do |key, value|
240 instance_variable_set "@#{key}".to_sym, value[:default]
242 instance_eval(&self.class.init) if self.class.init
245 def self.description(description=nil)
247 @description = description
253 def self.option(name, description=nil, default=nil)
255 @options[name] = {:description => description, :default => default}
262 def self.field(name, description=nil, default=nil)
264 @fields[name] = {:description => description, :default => default}
271 def self.default(format=nil, &block)
281 def self.init(&block)
291 instance_eval(@format)
293 case self.class.default
295 instance_eval(&self.class.default)
297 instance_eval(self.class.default)
305 description "Various data for the ALSA mixer"
306 option :mixer, "ALSA mixer name", "Master"
307 field :volume, "Volume in percentage", 0
311 IO.popen("amixer get #@mixer", IO::RDONLY) do |am|
313 volumes = out.scan(/\[(\d+)%\]/).flatten
314 volumes.each {|vol| @volume += vol.to_i }
315 @volume = @volume / volumes.size
320 class Battery < Widget
321 description "Remaining battery power in percentage"
322 option :battery, "Battery number", 1
323 field :percentage, "Power percentage", 0
324 default "@percentage"
327 batinfo = ProcFile.new("acpi/battery/BAT#@battery/info")[0]
328 batstate = ProcFile.new("acpi/battery/BAT#@battery/state")[0]
329 remaining = batstate["remaining capacity"].to_i
330 lastfull = batinfo["last full capacity"].to_i
331 @percentage = (remaining * 100) / lastfull.to_f
336 description "Displays date and time"
337 option :time_format, "Time format as described in DATE(1)", "%R"
338 field :time, "Formatted time"
342 @time = Time.now.strftime(@time_format)
346 class Maildir < Widget
347 description "Mail count in maildirs"
348 option :directories, "Globs of maildirs" # TODO: does a default make sense?
349 field :count, "Ammount of mail in searched directories", 0
353 raise WidgetError, "No directories configured" unless @directories
354 @directories.each do |glob|
355 glob = "#{ENV["HOME"]}/#{glob}" if glob[0] != ?/
356 @count += Dir["#{glob}/*"].size
361 class Memory < Widget
362 description "Various memory related data"
363 field :total, "Total kilobytes of memory", 0
364 field :free, "Free kilobytes of memory", 0
365 field :buffers, nil, 0 # TODO: description
366 field :cached, nil, 0 # TODO: description
367 field :usage, "Percentage of used memory", 0
371 meminfo = ProcFile.new("meminfo")[0]
372 @total = meminfo["MemTotal"].to_i
373 @free = meminfo["MemFree"].to_i
374 @buffers = meminfo["Buffers"].to_i
375 @cached = meminfo["Cached"].to_i
376 @usage = ((@total - @free - @cached - @buffers) * 100) / @total.to_f
380 class Raggle < Widget
381 description "Unread posts in raggle"
382 option :feed_list_path, "Path to feeds list", ".raggle/feeds.yaml"
383 option :feed_cache_path, "Path to feeds cache", ".raggle/feed_cache.store"
384 field :count, "Ammount of unread posts", 0
388 @feed_list_path = "#{ENV["HOME"]}/#@feed_list_path" if @feed_list_path[0] != ?/
389 feeds = YAML.load_file(@feed_list_path)
390 @feed_cache_path = "#{ENV["HOME"]}/#{@feed_cache_path}" if @feed_cache_path[0] != ?/
391 cache = PStore.new(@feed_cache_path)
392 cache.transaction(false) do
394 cache[feed["url"]].each do |item|
395 @count += 1 unless item["read?"]
403 # Parse and manage command line options
409 @options[:config] = "#{ENV["HOME"]}/.amazing.yml"
410 @options[:screens] = []
411 @options[:loglevel] = "info"
412 @options[:include] = []
413 @options[:update] = []
415 @parser = OptionParser.new do |opts|
416 opts.on("-c", "--config FILE", "Configuration file (~/.amazing.yml)") do |config|
417 @options[:config] = config
419 opts.on("-s", "--screen ID", "Screen ID (0)") do |screen|
420 @options[:screens] << screen
422 opts.on("-l", "--log-level LEVEL", "Severity threshold (info)") do |level|
423 @options[:loglevel] = level
425 opts.on("-i", "--include SCRIPT", "Include a widgets script") do |script|
426 @options[:include] << script
428 opts.on("-u", "--update WIDGET", "Update a widget and exit") do |widget|
429 @options[:update] << widget
431 opts.on("-w", "--list-widgets", "List available widgets") do
432 @options[:listwidgets] = true
434 opts.on("-h", "--help", "You're looking at it") do
435 @options[:help] = true
441 @options.keys.each do |key|
446 def parse(args=@args)
458 def []=(option, value)
459 @options[option] = value
463 # Command line interface runner
469 @log = Logger.new(STDOUT)
470 @options = Options.new(@args)
472 @display = X11::DisplayName.new
473 rescue X11::EmptyDisplayName => e
474 @log.warn("#{e.message}, falling back on :0")
475 @display = X11::DisplayName.new(":0")
476 rescue X11::InvalidDisplayName => e
477 @log.fatal("#{e.message}, exiting")
484 @log.fatal("Received SIGINT, exiting")
488 show_help if @options[:help]
492 list_widgets if @options[:listwidgets]
495 explicit_updates unless @options[:update].empty?
499 @config["widgets"].each do |widget_name, settings|
500 if settings["every"] && count % settings["every"] == 0
501 update_widget(widget_name)
518 @log.level = Logger.const_get(@options[:loglevel].upcase)
520 @log.error("Unsupported log level #{@options[:loglevel].inspect}")
521 @log.level = Logger::INFO
526 scripts = @options[:include]
527 @config["include"].each do |script|
528 script = "#{File.dirname(@options[:config])}/#{script}" if script[0] != ?/
531 scripts.each do |script|
532 if File.exist?(script)
533 Widgets.module_eval(File.read(script))
535 @log.error("No such widget script #{script.inspect}")
541 Widgets.constants.each do |widget|
542 if description = Widgets.const_get(widget).description
543 puts "#{widget}: #{description}"
552 @log.debug("Parsing configuration file")
554 @config = YAML.load_file(@options[:config])
556 @log.fatal("Unable to parse configuration file, exiting")
559 @config["include"] ||= []
560 @config["screens"] ||= []
565 @options[:screens].each do |screen|
566 @screens[screen.to_i] = Awesome.new(screen, @display.display)
569 @config["screens"].each do |screen|
570 @screens[screen] = Awesome.new(screen, @display.display)
573 @screens[0] = Awesome.new if @screens.empty?
577 @log.debug("Waiting for awesome control socket for display #{@display.display}")
579 Timeout.timeout(30) do
580 sleep 1 until File.exist?("#{ENV["HOME"]}/.awesome_ctl.#{@display.display}")
581 @log.debug("Got socket for display #{@display.display}")
583 rescue Timeout::Error
584 @log.fatal("Socket for display #{@display.display} not created within 30 seconds, exiting")
589 def update_non_interval
590 @config["widgets"].each do |widget_name, settings|
591 next if settings["every"]
592 update_widget(widget_name)
597 @config["widgets"].each_key do |widget_name|
598 next unless @options[:update].include? widget_name
599 update_widget(widget_name, false)
604 def update_widget(widget_name, threaded=true)
605 settings = @config["widgets"][widget_name]
607 @screens.each do |screen, awesome|
608 @log.debug("Updating widget #{widget_name} of type #{settings["type"]} on screen #{screen}")
609 opts = settings["options"] || {}
610 field = settings["field"] || "default"
612 widget = Widgets.const_get(settings["type"]).new(widget_name, settings["format"], opts)
613 awesome.widget_tell(widget_name, widget.formatize)
621 rescue WidgetError => e
622 @log.error(settings["type"]) { e.message }
629 Amazing::CLI.new(ARGV).run